Compare commits
6 Commits
spec-137
...
vision-check
| Author | SHA1 | Date | |
|---|---|---|---|
| af07314bb6 | |||
| cddd16bcdc | |||
| c1616b3162 | |||
| e1ba7fdabb | |||
| 2ac1f0c507 | |||
| e93e79c1bb |
+12
-2
@@ -55,13 +55,17 @@ DISCORD_CLIENT_SECRET=
|
||||
# The guild whose membership gates sign-in (Developer Mode -> right-click the
|
||||
# server -> Copy Server ID).
|
||||
DISCORD_GUILD_ID=
|
||||
# Optional: human-readable name for that guild, shown on the login screen so a
|
||||
# stranger knows which community owns this library and who to ask for an
|
||||
# invite. When unset the page falls back to a generic "private community"
|
||||
# label.
|
||||
# DISCORD_GUILD_NAME=
|
||||
# Exact callback URL, e.g. https://bookmark.example.com/auth/discord/callback.
|
||||
# Discord matches it verbatim, so it must equal the registered redirect.
|
||||
DISCORD_REDIRECT_URI=
|
||||
# Optional: a role snowflake members must hold on top of guild membership.
|
||||
# Empty (the default) means membership alone suffices.
|
||||
# DISCORD_REQUIRED_ROLE=
|
||||
|
||||
# Subdomain Traefik routes to the browser UI (required by the prod override).
|
||||
# Left commented on purpose: an example value here would be a silent
|
||||
# wrong-hostname fallback, and Traefik would publish the UI router on a domain
|
||||
@@ -100,7 +104,13 @@ DISCORD_REDIRECT_URI=
|
||||
# every kagane poll. Left unset here on purpose — a wrong default would poll a
|
||||
# stranger's address, and "no browser" is a safe, self-announcing state.
|
||||
# BROWSER_WS_URL=ws://100.x.y.z:9222
|
||||
|
||||
#
|
||||
# Discord webhook for owner notices (outbound alerting when a poll Lane
|
||||
# stalls). Unset means the whole path is off — a local stack needs no webhook,
|
||||
# exactly as the browser URL behaves. The address is a secret in the class of
|
||||
# TOKEN_KEY: never commit it, never paste it anywhere public.
|
||||
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||
#
|
||||
# Zone the backend stamps its log lines in. Cosmetic only. Nothing else in
|
||||
# the service has a zone: bookmark timestamps are unix ms, and the two real
|
||||
# time columns are timestamptz. Defaults to Asia/Jakarta; set to UTC for the
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
---
|
||||
target: backend/internal/web (admin surface)
|
||||
total_score: 22
|
||||
max_score: 40
|
||||
na_heuristics:
|
||||
p0_count: 1
|
||||
p1_count: 2
|
||||
timestamp: 2026-08-27T07-53-05Z
|
||||
slug: 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?
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
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?
|
||||
@@ -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"?
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
---
|
||||
target: Reader library web surface (app.html)
|
||||
total_score: 32
|
||||
max_score: 40
|
||||
na_heuristics:
|
||||
p0_count: 0
|
||||
p1_count: 2
|
||||
timestamp: 2026-08-27T15-38-17Z
|
||||
slug: backend-internal-web-templates-app-html
|
||||
---
|
||||
Method: dual-agent (A: CritiqueDesignReview · B: CritiqueDetectorEvidence)
|
||||
|
||||
Target: the Reader library surface — `backend/internal/web/templates/{app,chrome,list,card,setup,icons}.html`, `static/style.css`, `static/filter.js`. Mode: **Operate**.
|
||||
|
||||
## Design Health Score
|
||||
|
||||
| # | Heuristic | Score | Key Issue |
|
||||
|---|-----------|-------|-----------|
|
||||
| 1 | Visibility of System Status | 3 | `card.htmx-request::before` (style.css:880-889) animates a silent bar for up to the full 15s htmx timeout (app.html:12) with no "saving" text and no `aria-busy`. |
|
||||
| 2 | Match System / Real World | 4 | Solid. Copy is situational, not generic: "Chapter you're on" vs "Latest known" (card.html:96-106); four distinct empty states (list.html:11-29). |
|
||||
| 3 | User Control and Freedom | 3 | Confirm+Cancel and Esc-to-close cover accidental opens (filter.js:152-200); a committed Remove has no undo. |
|
||||
| 4 | Consistency and Standards | 3 | `:focus-visible` rings are defined for every `.actions` cell, the chapter input and confirm buttons (style.css:703-826) but for **no** navigation control — `.tabs a`, `.libswitch a`, `.ghost`, `.login-card button`, `.empty .clear-search` fall back to the UA ring. |
|
||||
| 5 | Error Prevention | 4 | `step="any"` chapter guard, confirm-gate on every move-out-of-list, `hx-disabled-elt` on every mutating control, Cancel focused on Remove (filter.js:159-164). |
|
||||
| 6 | Recognition Rather Than Recall | 3 | The `.keyrow` legend (chrome.html:34-46) exists precisely because the 5-icon strip is unlabelled — and it scrolls away, at 10px, in exactly the long-list case it was built for. |
|
||||
| 7 | Flexibility and Efficiency | 2 | No bulk actions, no sort, no shortcut beyond Esc. A 300-series library is triaged one card, two taps at a time. |
|
||||
| 8 | Aesthetic and Minimalist Design | 3 | Hairline-only, one-measure, disciplined — but the minimalism is paid for in legibility: 9 distinct functional labels compute to **10px** at 390px (Assessment B), below any UI-text floor. |
|
||||
| 9 | Error Recovery | 4 | `reasonFrom()` surfaces the server's literal 400 text (filter.js:270-292); distinct 401/400/network/timeout copy; auto-`scrollIntoView` to the message. |
|
||||
| 10 | Help and Documentation | 3 | In-context `<details class="setup">` covers install and rotation; nothing in the library view explains what dim/italic, ember, or brass mean without first finding the keyrow. |
|
||||
| **Total** | | **32/40** | **Good — solid foundation, weak areas are specific and cheap** |
|
||||
|
||||
## Design Specificity Verdict
|
||||
|
||||
**Authored, not assembled.** This could not ship unchanged on another product, and that is rare.
|
||||
|
||||
**LLM assessment (Assessment A, unanchored):** the specificity is load-bearing, not decorative. `--ember` is a contract, not an accent — every "new chapter" surface shares that one token (`.is-new .title` 604-610, `.tabs .tab-new` 409-412, `.foot-rule` 529-536, `.libswitch a.active` 663-667) and destruction is deliberately split onto a duller `--danger` family so a remove-confirm is never mistaken across the room for an unread chapter (style.css:77-83). The `:root` block carries per-pair contrast measurements, and the light branch **re-derives** every hue instead of reusing dark values (137-195) — evidence the "both branches touched together" law is honoured, not asserted. Three purpose-chosen faces, self-hosted because Bromite blocks `fonts.googleapis.com` (style.css:1-5). One `--measure: 760px` column, no cards, no shadows, and in 1075 lines exactly one stray corner (`.tabs a:hover` 2px radius, line 402). The gaps are not taste failures; they are unfinished passes — sticky positioning, focus rings on nav, and a legibility floor.
|
||||
|
||||
**Deterministic scan:** `detect.mjs --json backend/internal/web/templates` → **0 findings, exit 0**, and Assessment B validated that zero three ways (synthetic bad HTML fired 2 rules; `--no-config` reproduced the zero; no `config.json` exists). Note the scan's blind spot: the detector did **not** resolve the linked `/static/style.css`, so a clean markup scan says nothing about the tokens. The runtime pass is where everything was found.
|
||||
|
||||
**Visual overlays:** injection **succeeded** — but against a standalone render, not the live app. Assessment B found `web.go:125` parses templates with a plain `template.ParseFS` and no FuncMap, so it rendered the repo's real templates + real CSS from a throwaway program in `/tmp/bmrender`, served it, and injected `detect.js`. Overlay confirmed present; screenshots at `/tmp/bmrender/shots/` (390×844 light, dark, dark+overlay). **20 runtime findings, none of which the static scan could see:** `undersized-ui-text` ×18, `wide-tracking` ×1, `overused-font` ×1. Every card action measured **66.8 × 46 px** — clears 44px on both axes. No horizontal overflow at 390px (an earlier 499px reading was the overlay's own label boxes). Both servers stopped, no Docker started, no repo file changed.
|
||||
|
||||
False positives I accept from B: 9 of the 18 `undersized-ui-text` hits are the same `.hint` rule counted once per row inside `hidden` chapter forms — one defect, not nine. `overused-font` on Instrument Serif is the rule's share-of-text heuristic misreporting a deliberate display serif. `wide-tracking` anchors to the mono meta line, not body copy. **The remaining 9 are real and unanimous with A's typography read:** `Manga`, `Novels`, `Userscripts`, `Read`, `Fav`, `Chapter`, `Archive`, `Delete`, `Continue reading` all render at 10px.
|
||||
|
||||
## Overall Impression
|
||||
|
||||
This is the best-argued small codebase I have critiqued in a while — the CSS comments read like design review notes, and the taste is consistent. What works is the discipline: one heat signal, one column, hairlines, situational copy, and a confirm model that distinguishes reversible from final and even routes focus differently for each.
|
||||
|
||||
What does not work is that the design's own mitigations are undermined by two omissions it never got around to. The card strip needs the legend to be readable; the legend is 10px and scrolls away. The tab row is the primary navigation; it is the one row with no focus ring. Both are 20-line fixes.
|
||||
|
||||
**Single biggest opportunity: make the chrome persistent and lift the 10px floor.** Those two changes convert a well-reasoned desktop-shaped design into one that actually works one-handed at night on a phone — which PRODUCT.md says is the entire point.
|
||||
|
||||
## What's Working
|
||||
|
||||
1. **Ember law is real.** One reserved token for "unread chapter", nothing else borrows it, destruction gets its own duller family. This is why a dense icon-heavy list stays scannable to a tired reader: any warm colour means *something to read*. Most design systems claim this; this one enforces it in a comment and then actually holds the line for 1075 lines.
|
||||
2. **Confirm-row focus routing.** `toggleConfirmRow` focuses the affirmative button for the reversible action (Archive) and **Cancel** for the irreversible one (Remove) — filter.js:159-164, paired with `.confirm-row.calm` grey vs the danger wash (style.css:786-826). A one-line detail that measurably lowers accidental destruction. Nobody notices this; everybody benefits from it.
|
||||
3. **State coverage is exhaustive and honest.** Four distinct empty states by real cause, verbatim server 400 text surfaced inline instead of "something went wrong", a chapter form that disambiguates your progress from the published latest, and a cover slot with hatch + monogram + `onerror="this.remove()"` covering both "never acquired" and "404s after storage" (card.html:9-19). Verified in B's render: the broken-cover row hit `ERR_NAME_NOT_RESOLVED` and the monogram fallback executed.
|
||||
|
||||
## Priority Issues
|
||||
|
||||
**[P1] Nothing is sticky, on a mobile-first surface built for hundreds of series**
|
||||
- **Why it matters:** `position: sticky` appears **nowhere** in style.css (verified). Casey, one-handed, three screens into a 300-item library, must scroll to the top to switch tabs, search, or re-read the icon legend — the exact moment search and legend earn their existence. This also silently defeats the keyrow, which is the whole mitigation for the unlabelled 5-icon strip.
|
||||
- **Fix:** `.chrome` (searchbar + tabs) → `position: sticky; top: 0; background: var(--ink);` with `padding-top: env(safe-area-inset-top)` and a `z-index` above cards; keep `.recent`/`.keyrow` non-sticky if header height is tight, or sticky the keyrow instead and accept a shorter tab row.
|
||||
- **Suggested command:** `/impeccable adapt`
|
||||
|
||||
**[P1] Navigation controls have no `:focus-visible`; nine functional labels render at 10px**
|
||||
- **Why it matters:** two separate legibility/a11y regressions in the same layer. (a) Every `.actions` cell, the chapter input and confirm buttons define 2px token-coloured rings (style.css:703-826); `.tabs a`, `.libswitch a`, `.ghost`, `.login-card button` and `.empty .clear-search` define none, so keyboard focus on the primary navigation falls back to a UA ring — likely bright blue, against a palette explicitly tuned for low glare. `.search` goes further and zeroes its outline (372), delegating to a 1px `border-bottom-color` change — materially weaker than every other control in the file. (b) B measured 10px computed on `.libswitch a`, `.keyrow .pair span`, `.recent h2`, `.setup summary`, `.hint`. 10px mono, at night, on a phone, is not a minimalism choice a night-reading brief supports.
|
||||
- **Fix:** add `:focus-visible { outline: 2px solid var(--paper); outline-offset: 2px; }` to the four nav selectors, matching the established pattern; give `.search` a real ring instead of the hairline delegation. Raise the 10px steps to 11-12px (the `min-width: 720px` block already does exactly this for `.keyrow .pair span` at line 1024 — the mobile branch is the one that needs it more, not less).
|
||||
- **Suggested command:** `/impeccable audit` then `/impeccable typeset`
|
||||
|
||||
**[P2] Five icon-only actions per card, at rest, in both list states**
|
||||
- **Why it matters:** `.actions` renders Play, Fav, Chapter, Archive/Restore, Remove (card.html:48-87) — one over the ≤4 working-memory ceiling, on every card. At rest all five are `var(--mute)` grey; per-action colour is a *response* to interaction, not a resting cue. The design already knows this, which is why the keyrow exists — and the keyrow is 10px and scrolls away (P1). Fix P1 and this drops to P3; leave P1 and this is five monochrome glyphs versus a distracted thumb.
|
||||
- **Fix:** either resolve via P1 (sticky + legible legend) and keep five, or move Chapter-override behind the same disclosure affordance as Archive/Remove so the resting strip is Play, Fav, Archive, Remove.
|
||||
- **Suggested command:** `/impeccable layout`
|
||||
|
||||
**[P2] Up to 15 seconds of silent busy state**
|
||||
- **Why it matters:** `.card.htmx-request::before` animates a bar; there is no interim text, no `aria-busy`, and no live-region message until the outcome lands. app.html:8-12 documents the 15s cap and its own reasoning is "a phone that walks into a dead zone" — so the slow path is anticipated, and it is the one path with no feedback. To a distracted user this reads as "did my tap register?" for an uncomfortably long stretch; to a screen reader user there is literally nothing between the tap and the result.
|
||||
- **Fix:** after ~2s of `htmx-request`, reveal a "Saving…" line in the card's existing `.error-inline` slot (or a sibling) and set `aria-busy="true"` on the article.
|
||||
- **Suggested command:** `/impeccable harden`
|
||||
|
||||
**[P3] Long titles clamp in the strip but not in the list**
|
||||
- **Why it matters:** `.recent-title` clamps to 2 lines (style.css:512-517); `.title` gets only `overflow-wrap: anywhere` (592-601) despite the adjacent comment documenting a real 90-character scraped title. Horizontal overflow is defended; a card growing four lines tall and breaking the list's rhythm is not. Titles are scraped `og:title` — the length is not under your control.
|
||||
- **Fix:** apply the same `-webkit-line-clamp: 2` to `.title`, or decide the list shows titles in full and say why the two components diverge.
|
||||
- **Suggested command:** `/impeccable polish`
|
||||
|
||||
## Persona Red Flags
|
||||
|
||||
**Casey (distracted, one-handed, mobile — the stated primary user)**
|
||||
- No sticky chrome: every tab switch or search after scrolling is a full reach to the top of the viewport.
|
||||
- 10px `Manga`/`Novels` library switch and 10px keyrow labels — the two things you glance at, set at the smallest size on the page.
|
||||
- Five same-weight grey glyphs per card demand deliberate recognition, not a colour-coded glance.
|
||||
- *Working:* `.actions { flex: 1 0 100% }` puts the strip full-width at the bottom of each card's content — correct thumb zone. Every cell measured 66.8 × 46 px.
|
||||
|
||||
**Sam (screen reader / keyboard / low vision)**
|
||||
- `.tabs a` and `.libswitch a` have no custom focus ring; `.search:focus-visible { outline: none }` delegates to a 1px hairline recolour on the one control most in need of being findable.
|
||||
- No `aria-busy`/live cue during the in-flight window — announcements exist before nothing and after everything.
|
||||
- *Working, despite appearances:* cover `alt=""` + `aria-hidden` monogram is correctly decorative because the sibling `<h3 class="title">` carries the name; every icon button has both `aria-label` and `title`; mutations announce through `#sr-announce`.
|
||||
|
||||
**Riley (edge-case stress tester)**
|
||||
- Chapter `max="9999"` is a client-only cosmetic guard — server validation (`web.go:564-569`) rejects negative/NaN/Inf but no upper bound, so a direct POST stores an arbitrary magnitude.
|
||||
- `closeCardPanels` (filter.js:118-128) enforces one open panel *per card*, not per page: a confirm-remove can sit open on card 3 while a chapter form is open on card 40.
|
||||
- 300 series: no pagination, no virtualization, no bulk action, no sticky nav — sequential two-tap cleanup only.
|
||||
- *Working:* 0-series is handled with four distinct states including a true first-run with install links; missing covers are handled from both directions.
|
||||
|
||||
## Minor Observations
|
||||
|
||||
- `.keyrow .full { display: inline; }` (style.css:1025) targets a `.full` class that exists nowhere in `chrome.html` — dead rule, delete it.
|
||||
- `.tabs a:hover { border-radius: 2px 2px 0 0; }` (402) is the only corner rounding in the sheet, an unexplained exception to the stated no-corners law.
|
||||
- `.libswitch a` under `@media (pointer: coarse)` computes ~42px tall (1066-1068) against the file's own claim at 1061-1063 that "every control is already 44".
|
||||
- Restore fires instantly while Archive is confirm-gated — defensible as "reversible needs no confirm", but Restore *is* the reversal of Archive, so the asymmetry deserves a sentence in the CSS comment where the rule is stated.
|
||||
- The detector cannot see `style.css` through a `<link href="/static/...">`. Any future detector run on this repo must target rendered HTML in a browser, not the templates directory, or it will report clean and mean nothing.
|
||||
|
||||
## Questions to Consider
|
||||
|
||||
1. If a permanent legend is required to make the card strip legible, is 5 icon-only actions the right density for a phone — or is Chapter-override one disclosure away from a 4-icon strip that needs no legend?
|
||||
2. Why does neither the tab row nor the search box stay reachable once a Reader is three screens into the library the Updated badge exists to triage?
|
||||
3. Ember means "new chapter" everywhere except the wordmark and the login screen (style.css:238-241). Is a law with a carved-out exception still a law, or does the brand need its own accent instead of borrowing the one reserved signal?
|
||||
4. The 10px steps read as confidence — restraint pushed to its limit. On a surface whose brief names night reading and glare as an explicit personal requirement, is that restraint or is it the one place taste overrode the brief?
|
||||
@@ -50,6 +50,15 @@ Backend (`cd backend`):
|
||||
|
||||
Local stack: `docker compose up` (bookmark-api + postgres only; `postgres-data` named volume, `restart: unless-stopped`). No browser — without `BROWSER_WS_URL` the poller logs and skips kagane and comix. To run one: `cd chrome && BROWSER_BIND_ADDR=172.17.0.1 docker compose up -d --build`, then `BROWSER_WS_URL=ws://172.17.0.1:9222` in the root `.env` (bridge gateway, so the API container can name it by IP).
|
||||
|
||||
**Clean up Docker after testing.** Storage on the dev machine is scarce, so
|
||||
anything you started for a test you also tear down before calling the work
|
||||
done: `docker compose down -v` for a stack you brought up, `docker rm -f -v`
|
||||
for a container you ran by hand (`-v`, or the image's anonymous data volume
|
||||
survives). Then check `docker volume ls` and `docker system df` for leftovers
|
||||
and reclaim them — a dangling volume nobody notices is the leak that fills the
|
||||
disk. Never remove the `postgres-data` volume of a stack the user is actually
|
||||
running, and never blanket-`docker system prune` their images or build cache.
|
||||
|
||||
Live CDP proof (needs that browser and network, skipped otherwise):
|
||||
`SMOKE_BROWSER_WS_URL=ws://<ip>:<port> go test -run 'TestSmokeKagane|TestSmokeComix' ./internal/latest`
|
||||
— fetches a real kagane and comix cover and chapter list. A red run means the challenge is
|
||||
|
||||
@@ -136,6 +136,9 @@ gated by membership in one configured guild.
|
||||
DISCORD_REDIRECT_URI=https://bookmark.violetcrown.my.id/auth/discord/callback
|
||||
# Optional: only members holding this role may sign in.
|
||||
# DISCORD_REQUIRED_ROLE=<role snowflake>
|
||||
# Optional: display name for that guild — shown on the login screen so a
|
||||
# stranger knows which Discord to ask for an invite.
|
||||
# DISCORD_GUILD_NAME=Your Guild Name
|
||||
```
|
||||
|
||||
The guild id is in Discord's client with Developer Mode on: right-click the
|
||||
|
||||
+3
-3
@@ -8,7 +8,7 @@ web
|
||||
|
||||
## Users
|
||||
|
||||
Members of one private Discord guild, each with their own library. Accounts exist and are created by signing in — there is no signup form, no invite code and no approval step: any member of the configured guild becomes a Reader on their first Discord login. The person running the deployment is the owner, seeded at startup, and the only Reader with an administrative capability (revoking another Reader's sessions).
|
||||
Members of one private Discord guild, each with their own library. Accounts exist and are created by signing in — there is no signup form, no invite code and no approval step: any member of the configured guild becomes a Reader on their first Discord login. The person running the deployment is the owner, seeded at startup, and the only Reader with an administrative capability (`/admin` — Overview, Lanes, Readers, Series).
|
||||
|
||||
Reading happens on **asurascans.com**, **demonicscans.org**, **comix.to** and **kagane.to** for manga and **novelfull.com** and **lightnovelworld.net** for novels, primarily via Bromite on mobile, with checks and corrections from a desktop browser. The web UI is the cross-device view into progress the userscripts capture while reading.
|
||||
|
||||
@@ -34,7 +34,7 @@ Not a public reading tracker or social app — a private, self-hosted sync layer
|
||||
- "Continue reading" horizontal strip for series with an unread chapter.
|
||||
- A Reader with no bookmarks at all sees a deliberate empty library offering both userscript install links, not an error and not a blank page.
|
||||
- Isolation is the load-bearing invariant: two Readers cannot see or change each other's bookmarks. A series both track is one shared row polled once, with independent progress on each side.
|
||||
- The owner can revoke a specific Reader's sessions; nothing else in the UI differs by Reader.
|
||||
- The owner alone can reach `/admin` (Overview, Lanes, Readers, Series) for library/lane/series maintenance and Reader session/Sighting controls; otherwise nothing else in the UI differs by Reader.
|
||||
- htmx-driven partial updates, no client-side framework or build step — templates are Go `html/template`, `go:embed`-ed.
|
||||
- Mobile-first is a hard functional constraint (primary device is a phone), not just a starting breakpoint.
|
||||
|
||||
@@ -54,7 +54,7 @@ Not a public reading tracker or social app — a private, self-hosted sync layer
|
||||
- Mobile is the primary target; desktop is an enhancement, not the design center.
|
||||
- Progress data integrity over visual flourish: `updated_at`/list-ordering behavior is a correctness constraint the UI must respect, not decorate over.
|
||||
- A leak between Readers fails silently and looks like working software — isolation is asserted from both directions, never inferred from counting one Reader's rows.
|
||||
- No roles, no org chrome: the owner's Readers panel is one list with one button (revoke someone's sessions), not an admin console, and otherwise every Reader's view is the same.
|
||||
- No roles, no org chrome beyond the owner gate: `/admin` is the one owner-only console (Overview, Lanes, Readers, Series) for library-wide hygiene, lane health/pauses and series/Reader maintenance — Readers offers revoke sessions and clear Sighting marks, both confirm-gated — and otherwise every Reader's view is the same.
|
||||
- Prefer native platform affordances (system dark/light, native touch targets) over custom widgetry — this is a lean self-hosted tool, not a product to demo.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
@@ -48,6 +48,7 @@ covers are stored, so the library renders in full with the browser switched off.
|
||||
| `DISCORD_CLIENT_ID` | *(required)* | Discord application credentials for the browser sign-in (ADR-0002). |
|
||||
| `DISCORD_CLIENT_SECRET` | *(required)* | As above. Never logged, never echoed in an error. |
|
||||
| `DISCORD_GUILD_ID` | *(required)* | The one guild whose membership gates sign-in, checked at login only. Membership *is* registration: any member becomes a Reader on first login. |
|
||||
| `DISCORD_GUILD_NAME` | empty | Human-readable name for `DISCORD_GUILD_ID`, shown on the login screen so a stranger knows which community owns this library and who to ask for an invite. When unset the page shows a generic "private community" label. |
|
||||
| `DISCORD_REDIRECT_URI` | *(required)* | Exact callback URL; Discord matches it verbatim against the registered redirect. |
|
||||
| `DISCORD_REQUIRED_ROLE` | empty | Role snowflake a member must additionally hold. Empty means guild membership alone suffices. |
|
||||
| `DISCORD_API_BASE` | `https://discord.com/api/v10` | Test seam — tests point it at a local stub so the real token exchange runs. |
|
||||
|
||||
@@ -159,6 +159,15 @@ failures. The flag decays and they probe again.
|
||||
- Browser Lanes wake Chrome only when 5+ Series are due or one has waited 15m,
|
||||
and cover work runs in the background so a slow CDN can't eat a Lane's gap.
|
||||
|
||||
### Owner notices — `internal/notify`, `latest.Fault`, `latest.Notifier`, `latest.FaultsFrom`
|
||||
|
||||
The poller's outbound owner-notice path (issue #171): one condition today
|
||||
(the stall), judged from the durable pass log alone so the poller and any
|
||||
future reader of the same judgement cannot disagree. The webhook address is a
|
||||
secret in the class of TOKEN_KEY — never logged, never rendered, never
|
||||
carried in an error. The `owner_notices` suppression table (one row per
|
||||
condition + site) is the only state; every threshold is derived, not stored.
|
||||
|
||||
### Covers — `Store.OnSeriesCreated`, `latest.Acquirer`, `latest.CoverBytesFetcher`, `Store.SetSeriesCover`
|
||||
|
||||
Acquired once when the first Bookmark of a Series is created, then served from
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package latest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
// ConditionStall is the owner-notice machine word for a Lane that owed Polls,
|
||||
// made none, and has nothing to say for it. The word is the message's footer
|
||||
// and its suppression key; it is wire-stable. #172 declares the other three
|
||||
// words (no-browser-route, sidecar-down, adapter-broken); this ticket
|
||||
// declares only the stall.
|
||||
const ConditionStall = "stall"
|
||||
// ConditionNoBrowserRoute is the owner-notice machine word for a Site whose
|
||||
// challenge refuses for longer than the owner window with no browser route
|
||||
// to clear it — the 403-with-interstitial the reader maps to
|
||||
// errChallengeHeld (read.go), which plain TLS cannot clear. The word is the
|
||||
// message's footer and its suppression key; it is wire-stable.
|
||||
const ConditionNoBrowserRoute = "no-browser-route"
|
||||
|
||||
// ConditionSidecarDown is the owner-notice machine word for a browser
|
||||
// sidecar no Lane has reached for longer than the owner window: every
|
||||
// browser-backed Lane's latest pass is a sidecar skip. The word is the
|
||||
// message's footer and its suppression key; it is wire-stable, and its
|
||||
// suppression row holds the empty Site (AC4).
|
||||
const ConditionSidecarDown = "sidecar-down"
|
||||
|
||||
// ConditionAdapterBroken is the owner-notice machine word for a Site whose
|
||||
// adapter stopped finding chapters: more than half of its Series hold an
|
||||
// old no-chapter failure row. The word is the message's footer and its
|
||||
// suppression key; it is wire-stable.
|
||||
const ConditionAdapterBroken = "adapter-broken"
|
||||
|
||||
// OwnerWindow is the class-level staleness boundary every owner-notice
|
||||
// condition measures against — the same twelve hours the Lanes page's
|
||||
// "not checked in 12h" filter uses (internal/web/admin.go). Declared here
|
||||
// once so #172's three conditions and the admin filters share one figure.
|
||||
const OwnerWindow = 12 * time.Hour
|
||||
|
||||
// Fault is one condition the owner is told about, judged from durable rows
|
||||
// alone. Site is "" for a fault that is not one Site's.
|
||||
type Fault struct {
|
||||
Condition string // one of the Condition* words
|
||||
Site string
|
||||
Since int64 // unix ms the episode began; the message's age
|
||||
}
|
||||
|
||||
// FaultInput is everything the judgement reads. A struct so #172's three
|
||||
// conditions can add inputs without changing either caller.
|
||||
type FaultInput struct {
|
||||
Passes []store.LanePass
|
||||
// RefusingSince is, per Site, the unix ms when that Site's current
|
||||
// unbroken run of refusing passes began, or absent when its latest pass
|
||||
// did not refuse. Its zero value is an empty map, which contributes no
|
||||
// fault.
|
||||
RefusingSince map[string]int64
|
||||
|
||||
// SidecarOK is, per browser-backed Site, the unix ms of that Site's most
|
||||
// recent pass that actually reached the sidecar. Zero when the pass log
|
||||
// holds none — an asleep Lane never reached it and never counts as
|
||||
// evidence either way. Its zero value is an empty map.
|
||||
SidecarOK map[string]int64
|
||||
|
||||
// NoChapterShare is, per Site, the share of that Site's Series holding a
|
||||
// no-chapter failure row older than the owner window. Its zero value is
|
||||
// an empty map, which contributes no fault.
|
||||
NoChapterShare map[string]float64
|
||||
}
|
||||
|
||||
// FaultsFrom judges the owner-notice conditions from durable rows alone, so
|
||||
// the poller and the landing page cannot disagree about what a fault is.
|
||||
//
|
||||
// A Lane stalls when its latest pass shows due > 0, none checked, no skip
|
||||
// value and no refusal — exactly the row the mid-loop browser loss writes
|
||||
// (see the comment at the outcomeUnreachable return in runLanePass), so a
|
||||
// Lane that owed Polls, made none, and has nothing to say for it is a fault.
|
||||
// The three #172 conditions share the same OwnerWindow boundary and the same
|
||||
// fail-open shape: an input's absence contributes no fault, never a false
|
||||
// one.
|
||||
//
|
||||
// - no-browser-route: a Site whose refusing run began before the window
|
||||
// and that has no browser route to clear the challenge (AC2). Both
|
||||
// refusal shapes count — the gate's skip='refusing' rows and the loop's
|
||||
// refused>0 rows (the twice-refused break writes skip="") — so the run
|
||||
// stays unbroken across them, and one healthy pass ends it.
|
||||
// - sidecar-down: every browser-backed Site's latest pass is a sidecar
|
||||
// skip — SkipSidecarDown or SkipNoFetcher, the two early returns that
|
||||
// record a skip value — and each Site's most recent sidecar-reaching
|
||||
// pass is older than the window (AC4). The skip clause is what keeps
|
||||
// SkipAsleep out: a Lane under both wake thresholds is the commonest
|
||||
// healthy state, never reached the sidecar, and would otherwise age into
|
||||
// a false alarm. Emitted once, with Site "" (AC4's one row per episode).
|
||||
// - adapter-broken: more than half of one Site's Series hold a no-chapter
|
||||
// failure row older than the window (AC6). Strictly above half: the
|
||||
// filter is the tool, and one Site change makes hundreds of rows, so a
|
||||
// single failing Series never fires (AC7). The episode's age is the
|
||||
// window — the old rows prove the episode is at least that old.
|
||||
func FaultsFrom(in FaultInput, now time.Time) []Fault {
|
||||
var faults []Fault
|
||||
for _, p := range in.Passes {
|
||||
if p.Due > 0 && p.Checked == 0 && p.Skip == "" && p.Refused == 0 {
|
||||
faults = append(faults, Fault{Condition: ConditionStall, Site: p.Site, Since: p.RanAt})
|
||||
}
|
||||
}
|
||||
cutoff := now.Add(-OwnerWindow).UnixMilli()
|
||||
for site, since := range in.RefusingSince {
|
||||
if since < cutoff && !isBrowserSite(site) {
|
||||
faults = append(faults, Fault{Condition: ConditionNoBrowserRoute, Site: site, Since: since})
|
||||
}
|
||||
}
|
||||
if since, down := sidecarDownSince(in.Passes, in.SidecarOK, cutoff); down {
|
||||
faults = append(faults, Fault{Condition: ConditionSidecarDown, Site: "", Since: since})
|
||||
}
|
||||
for site, share := range in.NoChapterShare {
|
||||
if share > 0.5 {
|
||||
faults = append(faults, Fault{Condition: ConditionAdapterBroken, Site: site, Since: now.Add(-OwnerWindow).UnixMilli()})
|
||||
}
|
||||
}
|
||||
return faults
|
||||
}
|
||||
|
||||
// sidecarDownSince reports whether no browser Lane has reached the sidecar
|
||||
// for longer than the owner window and, when it has, the last moment any
|
||||
// Lane reached it. The skip clause — every browser-backed Site's latest pass
|
||||
// must be SkipSidecarDown or SkipNoFetcher — keeps SkipAsleep out (see
|
||||
// FaultsFrom). A Site with no pass row at all is not judged down either: a
|
||||
// fresh database is not a dead sidecar.
|
||||
func sidecarDownSince(passes []store.LanePass, ok map[string]int64, cutoff int64) (since int64, down bool) {
|
||||
latest := make(map[string]store.LanePass, len(passes))
|
||||
for _, p := range passes {
|
||||
latest[p.Site] = p
|
||||
}
|
||||
for _, site := range browserBackedSites() {
|
||||
p, found := latest[site]
|
||||
if !found || (p.Skip != SkipSidecarDown && p.Skip != SkipNoFetcher) {
|
||||
return 0, false
|
||||
}
|
||||
reached := ok[site]
|
||||
if reached > 0 && reached >= cutoff {
|
||||
return 0, false
|
||||
}
|
||||
if reached > since {
|
||||
since = reached
|
||||
}
|
||||
}
|
||||
// No Lane's retained pass log shows a sidecar reach: the honest age is
|
||||
// the window itself — "at least twelve hours" — not the epoch, which
|
||||
// humanAge would render as tens of thousands of days.
|
||||
if since == 0 {
|
||||
since = cutoff
|
||||
}
|
||||
return since, true
|
||||
}
|
||||
|
||||
// Notifier delivers one owner notice. The poller neither retries nor queues:
|
||||
// an error is logged and the suppression row left unwritten, so the next pass
|
||||
// tries again while the condition holds.
|
||||
type Notifier interface {
|
||||
Notify(ctx context.Context, f Fault, sentence, href string) error
|
||||
}
|
||||
|
||||
// ownerNoticeConditions is every condition this package judges, for the
|
||||
// clear loop in recordPass: a condition absent from a pass's fault list
|
||||
// forgets its episode, so the next occurrence sends again. #172 extends the
|
||||
// list when it adds its conditions.
|
||||
var ownerNoticeConditions = []string{ConditionStall, ConditionNoBrowserRoute, ConditionSidecarDown, ConditionAdapterBroken}
|
||||
|
||||
// noticeFor renders one fault's message: the description sentence — condition,
|
||||
// age, repair — and the deep link the embed's title points at. Each condition
|
||||
// provides its own wording; the stall is the only one today (issue #171).
|
||||
func noticeFor(f Fault, row store.LanePass, now time.Time) (sentence, href string) {
|
||||
switch f.Condition {
|
||||
case ConditionStall:
|
||||
return fmt.Sprintf(
|
||||
"%s owed %d Polls and made none — %s; check the Lane's browser sidecar and the Site's challenge state",
|
||||
f.Site, row.Due, humanAge(now.Sub(time.UnixMilli(f.Since)))), "/admin/lanes"
|
||||
case ConditionNoBrowserRoute:
|
||||
return fmt.Sprintf(
|
||||
"%s has refused for %s with no browser route — the challenge does not clear on plain TLS; redeploy or add a browser route",
|
||||
f.Site, humanAge(now.Sub(time.UnixMilli(f.Since)))), "/admin/lanes"
|
||||
case ConditionSidecarDown:
|
||||
return fmt.Sprintf(
|
||||
"no browser Lane has reached the sidecar for %s — the browser sidecar is down; start or repair the browser machine",
|
||||
humanAge(now.Sub(time.UnixMilli(f.Since)))), "/admin/lanes"
|
||||
case ConditionAdapterBroken:
|
||||
return fmt.Sprintf(
|
||||
"more than half of %s's Series have failed no-chapter reads for at least %s — the Site's layout changed and the adapter is broken",
|
||||
f.Site, humanAge(now.Sub(time.UnixMilli(f.Since)))), "/admin/lanes"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// humanAge renders a duration the way an owner reads it in a message: minutes
|
||||
// under an hour, then hours, then days; a stall that was just born reads
|
||||
// "just now".
|
||||
func humanAge(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh", int(d.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,10 @@ type Poller struct {
|
||||
// CoverBytesFetch is optional; it handles plain-TLS sources through the
|
||||
// same failure-isolated prefetch path.
|
||||
CoverBytesFetch CoverBytesFetcher
|
||||
Now func() time.Time // injected so tests can freeze it
|
||||
// Notify delivers owner notices. Nil disables the whole path (issue #171):
|
||||
// the poller is not the place a missing webhook becomes an error.
|
||||
Notify Notifier
|
||||
Now func() time.Time // injected so tests can freeze it
|
||||
// eligibleCount reports how many of a Site's Series are eligible for
|
||||
// polling, defaulting to Store.EligibleSeriesCount. Injected so tests can
|
||||
// fail the count alone: the eligible query shares the due query's tables,
|
||||
@@ -273,7 +276,9 @@ const (
|
||||
// (issue #141). The classification the read already makes is counted, never a
|
||||
// second taxonomy: refused is the Site holding a challenge, unreachable the
|
||||
// browser interrupting, noChapter a 200 with real HTML but no chapter links,
|
||||
// unfetchable the host pin or a missing fetcher, and errors everything else.
|
||||
// unfetchable the host pin or a missing fetcher, notFound a 4xx other than
|
||||
// the 403 refusal — the Site answered with a client status — and errors
|
||||
// everything else.
|
||||
type readOutcome int
|
||||
|
||||
const (
|
||||
@@ -283,14 +288,33 @@ const (
|
||||
outcomeNoChapter
|
||||
outcomeUnfetchable
|
||||
outcomeError
|
||||
outcomeNotFound
|
||||
)
|
||||
// word returns the wire spelling this outcome stores in poll_failures — the
|
||||
// same strings the pass log's columns use (C1, issue #164). Success, refusal
|
||||
// and browser loss return "" so recordFailure's "no statement" case is one
|
||||
// return: a challenge or a lost sidecar is no evidence about any particular
|
||||
// Series (ADR-0016).
|
||||
func (o readOutcome) word() string {
|
||||
switch o {
|
||||
case outcomeNotFound:
|
||||
return "not_found"
|
||||
case outcomeNoChapter:
|
||||
return "no_chapter"
|
||||
case outcomeUnfetchable:
|
||||
return "unfetchable"
|
||||
case outcomeError:
|
||||
return "errors"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// outcomeCounts are the five named outcome counts of one pass. A success
|
||||
// count is derived, never stored: checked minus the four, with unreachable
|
||||
// excluded because the sidecar-loss path returns before the checked counter
|
||||
// increments (issue #141).
|
||||
// outcomeCounts are the six named outcome counts of one pass. A success
|
||||
// count is derived, never stored: checked minus the five named failures,
|
||||
// with unreachable excluded because the sidecar-loss path returns before the
|
||||
// checked counter increments (issue #141).
|
||||
type outcomeCounts struct {
|
||||
refused, unreachable, noChapter, unfetchable, errors int
|
||||
refused, unreachable, noChapter, unfetchable, errors, notFound int
|
||||
}
|
||||
|
||||
func (c *outcomeCounts) add(o readOutcome) {
|
||||
@@ -303,6 +327,8 @@ func (c *outcomeCounts) add(o readOutcome) {
|
||||
c.noChapter++
|
||||
case outcomeUnfetchable:
|
||||
c.unfetchable++
|
||||
case outcomeNotFound:
|
||||
c.notFound++
|
||||
case outcomeError:
|
||||
c.errors++
|
||||
}
|
||||
@@ -334,7 +360,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
||||
// carries the previous pass's forward inside recordPass.
|
||||
fig := passFigures{}
|
||||
rec := passRecord{site: name, ranAt: now.UnixMilli()}
|
||||
defer func() { p.recordPass(rec, fig) }()
|
||||
defer func() { p.recordPass(ctx, rec, fig) }()
|
||||
|
||||
// One Lane row read at the top of a pass, serving two gates (issue #139).
|
||||
// Both stamps outlive our process, so the gates read the durable row
|
||||
@@ -451,6 +477,7 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
||||
}
|
||||
}
|
||||
outcome := p.checkOne(ctx, sr)
|
||||
p.recordFailure(sr, outcome)
|
||||
if outcome == outcomeUnreachable {
|
||||
// The mid-loop browser loss writes an empty skip on purpose: the
|
||||
// pass returns before the checked counter increments, so its row
|
||||
@@ -503,8 +530,9 @@ type passFigures struct {
|
||||
// pass's due, gap, clamped and checked forward rather than stating zeroes it
|
||||
// did not measure; the skip column says why it declined, so the zeroes that
|
||||
// remain (due-query, no-fetcher) read as explanations rather than
|
||||
// measurements.
|
||||
func (p *Poller) recordPass(rec passRecord, fig passFigures) {
|
||||
// measurements. Once the row is durable, the owner-notice judgement runs
|
||||
// beside it (issue #171).
|
||||
func (p *Poller) recordPass(ctx context.Context, rec passRecord, fig passFigures) {
|
||||
row := store.LanePass{
|
||||
Site: rec.site,
|
||||
RanAt: rec.ranAt,
|
||||
@@ -517,6 +545,7 @@ func (p *Poller) recordPass(rec passRecord, fig passFigures) {
|
||||
Unreachable: rec.counts.unreachable,
|
||||
NoChapter: rec.counts.noChapter,
|
||||
Unfetchable: rec.counts.unfetchable,
|
||||
NotFound: rec.counts.notFound,
|
||||
Errors: rec.counts.errors,
|
||||
}
|
||||
if row.GapMS == 0 {
|
||||
@@ -532,7 +561,111 @@ func (p *Poller) recordPass(rec passRecord, fig passFigures) {
|
||||
}
|
||||
if err := p.Store.RecordLanePass(row, rec.ranAt-lanePassRetention.Milliseconds()); err != nil {
|
||||
log.Printf("latest poll %s: record lane pass: %v", rec.site, err)
|
||||
return
|
||||
}
|
||||
p.ownerNotices(ctx, row)
|
||||
}
|
||||
|
||||
// ownerNotices judges the owner-notice conditions for the pass just recorded
|
||||
// and fires (issue #171). It sits in recordPass because that deferred call is
|
||||
// the one place every return path passes through: three of the four
|
||||
// conditions occur on early returns and the success path can never see them.
|
||||
// The judgement reads the whole pass log plus three derived reads — the
|
||||
// other Lanes' latest passes, each Site's refusing-run start, its last
|
||||
// sidecar-reaching pass, and its no-chapter share — so one pass judges every
|
||||
// condition (issue #172). Per fault: NoticeSent → send → MarkNoticeSent, so
|
||||
// a fault lasting a month sends one message, not one per pass; a condition
|
||||
// absent from this pass's fault list forgets its episode, so the next
|
||||
// occurrence sends again. Everything here is best-effort: a failed send, a
|
||||
// failed store read and a failed notice write are all logged and never
|
||||
// change the pass's outcome counts or its return value. The clear runs even
|
||||
// when Notify is nil, so a deployment that turns the webhook off does not
|
||||
// leave stale rows that suppress the first real notice after it is turned
|
||||
// back on.
|
||||
func (p *Poller) ownerNotices(ctx context.Context, row store.LanePass) {
|
||||
now := p.Now()
|
||||
in := FaultInput{Passes: []store.LanePass{row}}
|
||||
// Each read fails independently: a failure logs and contributes no fault,
|
||||
// never a false one.
|
||||
if passes, err := p.Store.LatestLanePasses(); err != nil {
|
||||
log.Printf("latest poll %s: latest lane passes: %v", row.Site, err)
|
||||
} else {
|
||||
in.Passes = passes
|
||||
}
|
||||
if since, err := p.Store.RefusingSince(now.UnixMilli()); err != nil {
|
||||
log.Printf("latest poll %s: refusing since: %v", row.Site, err)
|
||||
} else {
|
||||
in.RefusingSince = since
|
||||
}
|
||||
if ok, err := p.Store.SidecarOK(browserBackedSites()); err != nil {
|
||||
log.Printf("latest poll %s: sidecar ok: %v", row.Site, err)
|
||||
} else {
|
||||
in.SidecarOK = ok
|
||||
}
|
||||
if share, err := p.Store.NoChapterShare(now.Add(-OwnerWindow).UnixMilli()); err != nil {
|
||||
log.Printf("latest poll %s: no-chapter share: %v", row.Site, err)
|
||||
} else {
|
||||
in.NoChapterShare = share
|
||||
}
|
||||
faults := FaultsFrom(in, now)
|
||||
bySite := make(map[string]store.LanePass, len(in.Passes))
|
||||
for _, pass := range in.Passes {
|
||||
bySite[pass.Site] = pass
|
||||
}
|
||||
for _, f := range faults {
|
||||
if p.Notify == nil {
|
||||
continue
|
||||
}
|
||||
sent, err := p.Store.NoticeSent(f.Condition, f.Site)
|
||||
if err != nil {
|
||||
log.Printf("latest poll %s: notice sent: %v", row.Site, err)
|
||||
continue
|
||||
}
|
||||
if sent {
|
||||
continue
|
||||
}
|
||||
// The fault's own Site's pass renders its sentence — a stall judged
|
||||
// from another Lane's pass must not quote this pass's figures.
|
||||
pass, ok := bySite[f.Site]
|
||||
if !ok {
|
||||
pass = row
|
||||
}
|
||||
sentence, href := noticeFor(f, pass, now)
|
||||
if err := p.Notify.Notify(ctx, f, sentence, href); err != nil {
|
||||
// The stamp stays unset: no queue, no backoff — the condition is
|
||||
// durable, so the next pass tries again while it holds.
|
||||
log.Printf("latest poll %s: owner notice %s: %v", row.Site, f.Condition, err)
|
||||
continue
|
||||
}
|
||||
if err := p.Store.MarkNoticeSent(f.Condition, f.Site, now.UnixMilli()); err != nil {
|
||||
log.Printf("latest poll %s: mark notice sent: %v", row.Site, err)
|
||||
}
|
||||
}
|
||||
for _, cond := range ownerNoticeConditions {
|
||||
if !hasFault(faults, cond, row.Site) {
|
||||
if err := p.Store.ClearNotice(cond, row.Site); err != nil {
|
||||
log.Printf("latest poll %s: clear owner notice: %v", row.Site, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// sidecar-down suppresses under the empty Site — one row across all
|
||||
// browser Lanes (AC4) — so clear that row when it is absent from this
|
||||
// pass's fault list, and a lifted sidecar fires again when it returns.
|
||||
if !hasFault(faults, ConditionSidecarDown, "") {
|
||||
if err := p.Store.ClearNotice(ConditionSidecarDown, ""); err != nil {
|
||||
log.Printf("latest poll %s: clear owner notice: %v", row.Site, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hasFault reports whether faults hold the given condition for the site.
|
||||
func hasFault(faults []Fault, condition, site string) bool {
|
||||
for _, f := range faults {
|
||||
if f.Condition == condition && f.Site == site {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// countEligible routes the eligible count through the test seam when one is
|
||||
@@ -607,13 +740,35 @@ func maxSeriesWait(due []store.Series, now time.Time, rest time.Duration) time.D
|
||||
}
|
||||
return oldest
|
||||
}
|
||||
// recordFailure keeps one Series' failure row in step with its read
|
||||
// (ADR-0016): a failure word is upserted, a successful read deletes the row,
|
||||
// and refused or unreachable issue no statement at all. Called for every
|
||||
// outcome from the pass loop, so the four failure words and the success path
|
||||
// share one write point, and a forced Poll that reads the page clears through
|
||||
// the ordinary success path — no branch of its own. Log a store failure and
|
||||
// carry on: this is best-effort, and no single bad Series may stall a Lane.
|
||||
func (p *Poller) recordFailure(sr store.Series, outcome readOutcome) {
|
||||
if outcome == outcomeSuccess {
|
||||
if err := p.Store.ClearSeriesFailure(sr.Site, sr.SeriesID); err != nil {
|
||||
log.Printf("latest poll %q: clear failure: %v", sr.Key(), err)
|
||||
}
|
||||
return
|
||||
}
|
||||
word := outcome.word()
|
||||
if word == "" {
|
||||
return
|
||||
}
|
||||
if err := p.Store.RecordSeriesFailure(sr.Site, sr.SeriesID, word, p.Now().UnixMilli()); err != nil {
|
||||
log.Printf("latest poll %q: record failure: %v", sr.Key(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// checkOne re-checks one series. Every failure path here is "log and move on":
|
||||
// the poller is a best-effort enhancement, and no single bad series may stall a
|
||||
// Lane or take down the process. The returned outcome classifies the read for
|
||||
// the pass row (issue #141), so the Lane can count a refusal, a lost browser,
|
||||
// a chapter-less page, an unfetchable address or a transport error without
|
||||
// re-deriving the taxonomy.
|
||||
// a chapter-less page, an unfetchable address, a missing page or a transport
|
||||
// error without re-deriving the taxonomy.
|
||||
func (p *Poller) checkOne(ctx context.Context, sr store.Series) (outcome readOutcome) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
@@ -657,6 +812,9 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) (outcome readOut
|
||||
if errors.Is(err, errBrowserInterrupted) {
|
||||
return outcomeUnreachable
|
||||
}
|
||||
if errors.Is(err, errNotFound) {
|
||||
return outcomeNotFound
|
||||
}
|
||||
return outcomeError
|
||||
}
|
||||
// A legacy cover source is healed independently of the page read.
|
||||
@@ -669,6 +827,25 @@ func (p *Poller) checkOne(ctx context.Context, sr store.Series) (outcome readOut
|
||||
} else {
|
||||
p.fillBlankCover(ctx, sr, facts.Cover)
|
||||
}
|
||||
|
||||
// Learned from the successful read: the write sits after the error switch
|
||||
// (a refused, unreachable or errored read reaches nothing) and before the
|
||||
// returns below — a completed page whose chapter number did not change
|
||||
// still has to write. The transition is zero-versus-nonzero, not the
|
||||
// stamp's value: a Series still completed keeps its original stamp, so the
|
||||
// age #170 prints is "since the Site first said so"; one that stopped
|
||||
// being completed is zeroed.
|
||||
stamp := int64(0)
|
||||
if facts.SiteCompleted {
|
||||
stamp = p.Now().UnixMilli()
|
||||
}
|
||||
if (sr.SiteCompletedAt == 0) != (stamp == 0) {
|
||||
if err := p.Store.SetSiteCompletedAt(sr.Site, sr.SeriesID, stamp); err != nil {
|
||||
// Best-effort, like every poller write: never change the outcome
|
||||
// word the pass counts.
|
||||
log.Printf("latest poll %q: set site completed: %v", sr.Key(), err)
|
||||
}
|
||||
}
|
||||
if !facts.HasLatest {
|
||||
// Most likely a challenge page or a layout change. Either way the row is
|
||||
// already stamped, so this waits out a rest instead of hot-looping.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,15 +6,19 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// seriesRead carries the two facts the poll and the acquirer both extract
|
||||
// from a series page. Persistence, stamps and scheduling stay with the
|
||||
// callers, so the policies that keep the two flows distinct (stamp order,
|
||||
// rests) are not swallowed by the module.
|
||||
// seriesRead carries the facts the poll and the acquirer both extract from a
|
||||
// series page. Persistence, stamps and scheduling stay with the callers, so
|
||||
// the policies that keep the two flows distinct (stamp order, rests) are not
|
||||
// swallowed by the module.
|
||||
type seriesRead struct {
|
||||
Latest latestChapter
|
||||
HasLatest bool
|
||||
Cover string
|
||||
HasCover bool
|
||||
// SiteCompleted is whether the Site's own completed value was on the page.
|
||||
// A challenge body and a redesign both read false — an absent hint, never
|
||||
// a claim (issue #168).
|
||||
SiteCompleted bool
|
||||
// BodyLen is the fetched body's length, surfaced because the no-chapter
|
||||
// log uses it to tell a markup change from a body the size cap cut short.
|
||||
BodyLen int
|
||||
@@ -25,15 +29,18 @@ type seriesRead struct {
|
||||
// errChallengeHeld (browser.go) is the outcome of a Site that answered with
|
||||
// its interstitial — status 403 (cf-mitigated) or a challenge page body — and
|
||||
// is how a Lane tells a refusal from an ordinary failure (issue #100).
|
||||
// errNotFound marks any other 4xx: the page is gone — a fact the owner can act
|
||||
// on — as distinct from a Site or database that is merely unwell (issue #164).
|
||||
var (
|
||||
errNotFetchable = errors.New("series url not fetchable")
|
||||
errNoFetcher = errors.New("no fetcher for site")
|
||||
errNotFound = errors.New("series page not found")
|
||||
)
|
||||
|
||||
// readSeriesPage performs the series-page read the poll and the acquirer have
|
||||
// in common: gate the address, choose the route, fetch the page, extract the
|
||||
// Latest Chapter and the Cover address. It persists nothing and stamps
|
||||
// nothing.
|
||||
// Latest Chapter, the Cover address and the Site's completed value. It
|
||||
// persists nothing and stamps nothing.
|
||||
//
|
||||
// series_url arrives in a client-supplied PUT body (PUT /bookmarks/{key}
|
||||
// accepts any string), so the gate is not an optimisation against burning a
|
||||
@@ -59,6 +66,9 @@ func readSeriesPage(ctx context.Context, site, seriesURL string, browser, tls Fe
|
||||
return seriesRead{}, fmt.Errorf("%w: fetch %s: status %d", errChallengeHeld, seriesURL, status)
|
||||
}
|
||||
if status != 200 {
|
||||
if status >= 400 && status < 500 {
|
||||
return seriesRead{}, fmt.Errorf("%w: fetch %s: status %d", errNotFound, seriesURL, status)
|
||||
}
|
||||
return seriesRead{}, fmt.Errorf("fetch %s: status %d", seriesURL, status)
|
||||
}
|
||||
if isInterstitial(body) {
|
||||
@@ -69,5 +79,12 @@ func readSeriesPage(ctx context.Context, site, seriesURL string, browser, tls Fe
|
||||
}
|
||||
latest, hasLatest := latestChapterFrom(site, seriesURL, body)
|
||||
cover, hasCover := coverFrom(site, seriesURL, body)
|
||||
return seriesRead{Latest: latest, HasLatest: hasLatest, Cover: cover, HasCover: hasCover, BodyLen: len(body)}, nil
|
||||
return seriesRead{
|
||||
Latest: latest,
|
||||
HasLatest: hasLatest,
|
||||
Cover: cover,
|
||||
HasCover: hasCover,
|
||||
SiteCompleted: siteCompletedFrom(site, seriesURL, body),
|
||||
BodyLen: len(body),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ type latestChapter struct {
|
||||
|
||||
// site answers the fixed questions every series-page read asks of its Site
|
||||
// (ADR-0009): the host its addresses must carry, how to find the Latest
|
||||
// Chapter and the Cover address in a body, and — for a Site behind a
|
||||
// JavaScript challenge — how to read its payload from a cleared tab. One
|
||||
// entry describes everything about one Site, and nowhere else gets to compare
|
||||
// the site string.
|
||||
// Chapter and the Cover address in a body, whether the Site calls the work
|
||||
// completed, and — for a Site behind a JavaScript challenge — how to read its
|
||||
// payload from a cleared tab. One entry describes everything about one Site,
|
||||
// and nowhere else gets to compare the site string.
|
||||
type site struct {
|
||||
// Host is the exact hostname a series_url for this Site must carry.
|
||||
Host string
|
||||
@@ -33,6 +33,10 @@ type site struct {
|
||||
LatestChapter func(seriesURL, body string) (latestChapter, bool)
|
||||
// Cover finds the Cover address in a fetched body.
|
||||
Cover func(seriesURL, body string) (string, bool)
|
||||
// Completed reports whether this body carries the Site's own completed
|
||||
// value. False for a body that carries any other value, and false for a
|
||||
// failed extraction — never an error and never a third state.
|
||||
Completed func(seriesURL, body string) bool
|
||||
// Rest is how long a Series of this Site rests between Polls.
|
||||
Rest time.Duration
|
||||
// Gap is the Lane's strictest pace: at least one second must pass between
|
||||
@@ -299,34 +303,42 @@ func kaganeCoverURL(body string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func comixCoverURL(seriesURL, body string) string {
|
||||
// comixDetailQuery returns the ["manga","detail","<id>"] query entry of
|
||||
// comix's initial-data JSON, or nil. The cover and completed reads share the
|
||||
// scoped lookup so a "recommended" strip entry can never contribute either
|
||||
// answer.
|
||||
func comixDetailQuery(seriesURL, body string) json.RawMessage {
|
||||
id, ok := comixSeriesID(seriesURL)
|
||||
if !ok {
|
||||
return ""
|
||||
return nil
|
||||
}
|
||||
data := comixInitialDataRe.FindStringSubmatch(body)
|
||||
if data == nil {
|
||||
return ""
|
||||
return nil
|
||||
}
|
||||
var state struct {
|
||||
Queries map[string]json.RawMessage `json:"queries"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data[1]), &state); err != nil {
|
||||
return nil
|
||||
}
|
||||
return state.Queries[`["manga","detail","`+id+`"]`]
|
||||
}
|
||||
|
||||
func comixCoverURL(seriesURL, body string) string {
|
||||
detail := comixDetailQuery(seriesURL, body)
|
||||
if len(detail) == 0 {
|
||||
return ""
|
||||
}
|
||||
raw := state.Queries[`["manga","detail","`+id+`"]`]
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var detail struct {
|
||||
var entry struct {
|
||||
Poster struct {
|
||||
Medium string `json:"medium"`
|
||||
} `json:"poster"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &detail); err != nil {
|
||||
if err := json.Unmarshal(detail, &entry); err != nil {
|
||||
return ""
|
||||
}
|
||||
return publishedCoverURL(detail.Poster.Medium)
|
||||
return publishedCoverURL(entry.Poster.Medium)
|
||||
}
|
||||
|
||||
// ogImageCover reads the og:image metadata shared by asura, demonic and
|
||||
@@ -360,6 +372,87 @@ func coverFrom(site, seriesURL, body string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// asuraStatusRe matches the status value inside the escaped astro-island
|
||||
// props blob, in both the " form the served document carries and the "
|
||||
// form a decoded copy would. "completed" is the only true value: "dropped"
|
||||
// is scanlation editorial (the work itself continues elsewhere) and hiatus is
|
||||
// its own value.
|
||||
var asuraStatusRe = regexp.MustCompile(`(?:"|")status(?:"|"):\[0,(?:"|")completed(?:"|")\]`)
|
||||
|
||||
func asuraCompleted(_, body string) bool {
|
||||
return asuraStatusRe.MatchString(body)
|
||||
}
|
||||
|
||||
// demonicStatusRe matches the info block's status pair: a Status label <li>
|
||||
// immediately followed by the value <li>. The site's whole status vocabulary
|
||||
// is {Ongoing, Completed} (its advanced-search status filter), so the literal
|
||||
// Completed value is the entire signal.
|
||||
var demonicStatusRe = regexp.MustCompile(`<li[^>]*>\s*Status\s*</li>\s*<li[^>]*>\s*Completed\s*</li>`)
|
||||
|
||||
func demonicCompleted(_, body string) bool {
|
||||
return demonicStatusRe.MatchString(body)
|
||||
}
|
||||
|
||||
// comixCompleted reads "status" from the scoped detail entry only;
|
||||
// "finished" is the completed value, and on_hiatus and discontinued are
|
||||
// distinct values.
|
||||
func comixCompleted(seriesURL, body string) bool {
|
||||
detail := comixDetailQuery(seriesURL, body)
|
||||
if len(detail) == 0 {
|
||||
return false
|
||||
}
|
||||
var entry struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(detail, &entry); err != nil {
|
||||
return false
|
||||
}
|
||||
return entry.Status == "finished"
|
||||
}
|
||||
|
||||
// kaganeCompleted reads publication_status only: upload_status is the
|
||||
// release's state, and the two provably diverge ('Cause Calypso Can,
|
||||
// 2026-08-19: publication Ongoing, upload Hiatus), so a Completed upload
|
||||
// must never read as a Completed work.
|
||||
func kaganeCompleted(_, body string) bool {
|
||||
var series struct {
|
||||
PublicationStatus string `json:"publication_status"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &series); err != nil {
|
||||
return false
|
||||
}
|
||||
return series.PublicationStatus == "Completed"
|
||||
}
|
||||
|
||||
// novelfullStatusRe matches the info panel's status link. The page's whole
|
||||
// status vocabulary is {Ongoing, Completed} (the "OnGoing" spelling aliases
|
||||
// "Ongoing" on the taxonomy), so the Completed href is the signal.
|
||||
var novelfullStatusRe = regexp.MustCompile(`href="/status/Completed"`)
|
||||
|
||||
func novelfullCompleted(_, body string) bool {
|
||||
return novelfullStatusRe.MatchString(body)
|
||||
}
|
||||
|
||||
// lnwCompleted matches creativeWorkStatus in the head's JSON-LD block. The
|
||||
// whole body is scanned and the comment marker is not required, unlike
|
||||
// lnwLatestChapter: the status block sits ahead of the visitor-writable
|
||||
// thread, and hiatus maps to a distinct PotentialActionStatus value.
|
||||
var lnwStatusRe = regexp.MustCompile(`"creativeWorkStatus"\s*:\s*"https://schema\.org/CompletedActionStatus"`)
|
||||
|
||||
func lnwCompleted(_, body string) bool {
|
||||
return lnwStatusRe.MatchString(body)
|
||||
}
|
||||
|
||||
// siteCompletedFrom reports whether the Site calls this work completed, via
|
||||
// the Site's registry entry. False for an unknown site, a challenge body and
|
||||
// a redesign alike: an absent hint, never a claim.
|
||||
func siteCompletedFrom(site, seriesURL, body string) bool {
|
||||
if fn := sites[site].Completed; fn != nil {
|
||||
return fn(seriesURL, body)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// metaContent returns the content of the first <meta> whose attrName is
|
||||
// attrValue. It keeps scanning after an empty match so a later published cover
|
||||
// is not hidden by an empty tag.
|
||||
@@ -449,6 +542,7 @@ var sites = map[string]site{
|
||||
Host: "asurascans.com",
|
||||
LatestChapter: asuraLatestChapter,
|
||||
Cover: ogImageCover,
|
||||
Completed: asuraCompleted,
|
||||
Rest: defaultRest,
|
||||
Gap: defaultGap,
|
||||
},
|
||||
@@ -456,6 +550,7 @@ var sites = map[string]site{
|
||||
Host: "demonicscans.org",
|
||||
LatestChapter: demonicLatestChapter,
|
||||
Cover: ogImageCover,
|
||||
Completed: demonicCompleted,
|
||||
Rest: defaultRest,
|
||||
Gap: defaultGap,
|
||||
},
|
||||
@@ -463,6 +558,7 @@ var sites = map[string]site{
|
||||
Host: "comix.to",
|
||||
LatestChapter: comixLatestChapter,
|
||||
Cover: comixCoverEntry,
|
||||
Completed: comixCompleted,
|
||||
Rest: defaultRest,
|
||||
Gap: defaultGap,
|
||||
Browser: &browserRead{
|
||||
@@ -479,6 +575,7 @@ var sites = map[string]site{
|
||||
Host: "kagane.to",
|
||||
LatestChapter: kaganeLatestChapter,
|
||||
Cover: kaganeCoverEntry,
|
||||
Completed: kaganeCompleted,
|
||||
Rest: defaultRest,
|
||||
Gap: defaultGap,
|
||||
Browser: &browserRead{
|
||||
@@ -493,6 +590,7 @@ var sites = map[string]site{
|
||||
Host: "novelfull.com",
|
||||
LatestChapter: novelfullLatestChapter,
|
||||
Cover: novelfullCoverEntry,
|
||||
Completed: novelfullCompleted,
|
||||
Rest: defaultRest,
|
||||
Gap: defaultGap,
|
||||
Browser: &browserRead{
|
||||
@@ -507,6 +605,7 @@ var sites = map[string]site{
|
||||
Host: "lightnovelworld.net",
|
||||
LatestChapter: lnwLatestChapter,
|
||||
Cover: ogImageCover,
|
||||
Completed: lnwCompleted,
|
||||
Rest: defaultRest,
|
||||
Gap: defaultGap,
|
||||
},
|
||||
@@ -524,6 +623,12 @@ func SiteNames() []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// BrowserBackedSites is derived from the registry: the Sites whose pages are
|
||||
// read through the browser sidecar. Sorted so callers that range it (the
|
||||
// browser fetcher's dispatch) see a stable order instead of map-iteration
|
||||
// noise. Exported so the web layer shares the same set the poller does.
|
||||
func BrowserBackedSites() []string { return browserBackedSites() }
|
||||
|
||||
// browserBackedSites is derived from the registry: the Sites whose pages are
|
||||
// read through the browser sidecar. Sorted so callers that range it (the
|
||||
// browser fetcher's dispatch) see a stable order instead of map-iteration
|
||||
|
||||
@@ -188,6 +188,104 @@ const lnwSeriesFixture = `
|
||||
</div>
|
||||
`
|
||||
|
||||
// Completed-marker fixtures, trimmed from the live pages fetched 2026-08-22
|
||||
// for the verification step below. Selector-removed rows delete the marker
|
||||
// from the consts and must answer false.
|
||||
|
||||
// Trimmed from https://asurascans.com/comics/solo-leveling (301 to
|
||||
// /comics/solo-leveling-b60d532c) fetched 2026-08-22 by a curl probe from
|
||||
// this machine. The astro-island props are HTML-escaped in the served
|
||||
// document, so the quotes arrive as ".
|
||||
const asuraCompletedFixture = `
|
||||
"bookmarkCount":[0,39128],"status":[0,"completed"],"type":[0,"manhwa"]
|
||||
`
|
||||
|
||||
// Trimmed from https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af
|
||||
// fetched 2026-08-22 by a curl probe.
|
||||
const asuraOngoingFixture = `
|
||||
"bookmarkCount":[0,54027],"status":[0,"ongoing"],"type":[0,"manhwa"]
|
||||
`
|
||||
|
||||
// Trimmed from https://demonicscans.org/manga/Solo-Leveling fetched
|
||||
// 2026-08-22 by a curl probe. The info block is sloppy: bare <li>s inside a
|
||||
// <div>, label and value as a sibling pair.
|
||||
const demonicCompletedFixture = `
|
||||
<div class="flex flex-row">
|
||||
<li style="width:150px;color:#b2b2b2;">Status</li>
|
||||
<li>Completed</li>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Trimmed from https://demonicscans.org/manga/Catastrophic-Necromancer
|
||||
// fetched 2026-08-22 by a curl probe. Same block, ongoing value.
|
||||
const demonicOngoingFixture = `
|
||||
<div class="flex flex-row">
|
||||
<li style="width:150px;color:#b2b2b2;">Status</li>
|
||||
<li>Ongoing</li>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Trimmed from https://comix.to/title/q77m-countach fetched 2026-08-22 by a
|
||||
// cleared Chrome tab (the CDP sidecar: the Series URL is fetched in-tab, the
|
||||
// same server-rendered payload the poll reads). The served page escapes the
|
||||
// query map keys as \u0022; the fixture carries the decoded form, which
|
||||
// parses to the same key. The recommended strip on this very page carries a
|
||||
// finished entry; only the ["manga","detail","q77m"] entry counts.
|
||||
const comixCompletedFixture = `<script type="application/json" id="initial-data">{"queries":{"[\"manga\",\"recommended\",\"q77m\",1]":{"items":[{"hid":"pz65","title":"Wangan Midnight: C1 Runner","status":"finished"}]},"[\"manga\",\"detail\",\"q77m\"]":{"id":13211,"hid":"q77m","title":"Countach","status":"finished","originalLanguage":"ja"}}}</script>`
|
||||
|
||||
// Trimmed from https://comix.to/title/n8we-dungeons-and-crayons fetched
|
||||
// 2026-08-22 the same way (keys in their decoded form, as above). The
|
||||
// recommended strip includes a finished entry; the target detail is
|
||||
// releasing, and only the detail read counts.
|
||||
const comixOngoingFixture = `<script type="application/json" id="initial-data">{"queries":{"[\"manga\",\"recommended\",\"n8we\",1]":{"items":[{"hid":"g2rk","title":"On the Way to Meet Mom","status":"finished"}]},"[\"manga\",\"detail\",\"n8we\"]":{"id":1429,"hid":"n8we","title":"Dungeons and Crayons","status":"releasing","originalLanguage":"ko"}}}</script>`
|
||||
|
||||
// Trimmed from GET https://kagane.to/api/v2/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b
|
||||
// fetched 2026-08-22 in a cleared Chrome tab (plain TLS serves the Cloudflare
|
||||
// challenge).
|
||||
const kaganeOngoingFixture = `{"series_id":"019f84bc-9ba0-7ed9-86f5-8b905ec7c28b","title":"Infinite Decryption: The Strongest Level 0","publication_status":"Ongoing","upload_status":"Ongoing"}`
|
||||
|
||||
// Trimmed from GET https://kagane.to/api/v2/series/019c29c3-5abd-70e6-9efc-1f1f22d839f6
|
||||
// fetched 2026-08-22 the same way.
|
||||
const kaganeCompletedFixture = `{"series_id":"019c29c3-5abd-70e6-9efc-1f1f22d839f6","title":"Real Account 1-17","publication_status":"Completed","upload_status":"Completed"}`
|
||||
|
||||
// Composed, not a single live trim: upload Completed alongside a
|
||||
// non-Completed publication status is the research note's inferred inverse
|
||||
// case and did not turn up in the ~1900-series live scan of 2026-08-22 (both
|
||||
// field vocabularies are live-verified; the note's live divergence is 'Cause
|
||||
// Calypso Can, publication Ongoing + upload Hiatus). The predicate must read
|
||||
// publication_status only.
|
||||
const kaganeDivergentFixture = `{"series_id":"019f84bc-9ba0-7ed9-86f5-8b905ec7c28b","title":"Infinite Decryption: The Strongest Level 0","publication_status":"Ongoing","upload_status":"Completed"}`
|
||||
|
||||
// Trimmed from https://novelfull.com/reverend-insanity.html fetched
|
||||
// 2026-08-22 in a cleared Chrome tab after the plain-TLS probe was served the
|
||||
// challenge (time-varying; plain curl answered 403 the same day).
|
||||
const novelfullCompletedFixture = `<div><h3>Status:</h3><a href="/status/Completed">Completed</a></div>`
|
||||
|
||||
// Trimmed from https://novelfull.com/a-cunning-pervert-in-the-cultivation-world.html
|
||||
// fetched 2026-08-22 the same way.
|
||||
const novelfullOngoingFixture = `<div><h3>Status:</h3><a href="/status/Ongoing">Ongoing</a></div>`
|
||||
|
||||
// Trimmed from the JSON-LD block in the head of
|
||||
// https://lightnovelworld.net/novel/a-will-eternal/ fetched 2026-08-22. The
|
||||
// block sits ahead of the wpdiscuz thread, so the predicate reads the whole
|
||||
// body and needs no comment marker, unlike lnwLatestChapter.
|
||||
const lnwCompletedFixture = `<script type="application/ld+json">{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Book",
|
||||
"name": "A Will Eternal",
|
||||
"creativeWorkStatus": "https://schema.org/CompletedActionStatus"
|
||||
}</script>`
|
||||
|
||||
// Trimmed from
|
||||
// https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/
|
||||
// fetched 2026-08-22. Same block, ongoing value.
|
||||
const lnwOngoingFixture = `<script type="application/ld+json">{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Book",
|
||||
"name": "All Jobs and Classes I Just Wanted One Skill Not Them All",
|
||||
"creativeWorkStatus": "https://schema.org/ActiveActionStatus"
|
||||
}</script>`
|
||||
|
||||
// Trimmed from https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af
|
||||
// (redirected to ...-00dcbf97) on 2026-08-10.
|
||||
const asuraCoverFixture = `<meta property="og:image" content="https://cdn.asurascans.com/asura-images/covers/chronicles-of-the-demon-faction.d4dcb8.webp">`
|
||||
@@ -473,3 +571,178 @@ func TestLatestChapterFrom(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteCompletedFrom(t *testing.T) {
|
||||
const asuraURL = "https://asurascans.com/comics/solo-leveling-b60d532c"
|
||||
const demonicURL = "https://demonicscans.org/manga/Solo-Leveling"
|
||||
const comixURL = "https://comix.to/title/q77m-countach"
|
||||
const kaganeURL = "https://kagane.to/series/019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"
|
||||
const novelfullURL = "https://novelfull.com/reverend-insanity.html"
|
||||
const lnwURL = "https://lightnovelworld.net/novel/a-will-eternal/"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
site string
|
||||
seriesURL string
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "asura completed via escaped props status",
|
||||
site: "asura", seriesURL: asuraURL, body: asuraCompletedFixture,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "asura ongoing value is not completed",
|
||||
site: "asura",
|
||||
seriesURL: "https://asurascans.com/comics/chronicles-of-the-demon-faction-f886a8af",
|
||||
body: asuraOngoingFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "asura with the status key removed",
|
||||
site: "asura", seriesURL: asuraURL,
|
||||
body: strings.ReplaceAll(asuraCompletedFixture, `"status":[0,"completed"]`, ""),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "asura challenge page",
|
||||
site: "asura", seriesURL: asuraURL, body: challengeFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "demonic completed info-block pair",
|
||||
site: "demonic", seriesURL: demonicURL, body: demonicCompletedFixture,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "demonic ongoing value is not completed",
|
||||
site: "demonic",
|
||||
seriesURL: "https://demonicscans.org/manga/Catastrophic-Necromancer",
|
||||
body: demonicOngoingFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "demonic with the value li removed",
|
||||
site: "demonic", seriesURL: demonicURL,
|
||||
body: strings.ReplaceAll(demonicCompletedFixture, "<li>Completed</li>", ""),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "comix recommended finished does not count",
|
||||
site: "comix",
|
||||
seriesURL: "https://comix.to/title/n8we-dungeons-and-crayons",
|
||||
body: comixOngoingFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "comix detail finished wins over a finished recommended strip",
|
||||
site: "comix",
|
||||
seriesURL: comixURL,
|
||||
body: comixCompletedFixture,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "comix with the detail status removed",
|
||||
site: "comix", seriesURL: comixURL,
|
||||
body: strings.ReplaceAll(comixCompletedFixture, `"status":"finished",`, ""),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "comix challenge page",
|
||||
site: "comix", seriesURL: comixURL, body: challengeFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "kagane publication Completed",
|
||||
site: "kagane",
|
||||
seriesURL: "https://kagane.to/series/019c29c3-5abd-70e6-9efc-1f1f22d839f6",
|
||||
body: kaganeCompletedFixture,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "kagane upload Completed does not count",
|
||||
site: "kagane", seriesURL: kaganeURL, body: kaganeDivergentFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "kagane ongoing values are not completed",
|
||||
site: "kagane", seriesURL: kaganeURL, body: kaganeOngoingFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "kagane with publication_status removed",
|
||||
site: "kagane", seriesURL: kaganeURL,
|
||||
body: strings.ReplaceAll(kaganeCompletedFixture, `"publication_status":"Completed",`, ""),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "kagane challenge page",
|
||||
site: "kagane", seriesURL: kaganeURL, body: challengeFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "novelfull status link Completed",
|
||||
site: "novelfull",
|
||||
seriesURL: novelfullURL,
|
||||
body: novelfullCompletedFixture,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "novelfull ongoing link is not completed",
|
||||
site: "novelfull",
|
||||
seriesURL: "https://novelfull.com/a-cunning-pervert-in-the-cultivation-world.html",
|
||||
body: novelfullOngoingFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "novelfull with the status link removed",
|
||||
site: "novelfull", seriesURL: novelfullURL,
|
||||
body: strings.ReplaceAll(novelfullCompletedFixture, `<a href="/status/Completed">Completed</a>`, ""),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "novelfull challenge page",
|
||||
site: "novelfull", seriesURL: novelfullURL, body: challengeFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "lightnovelworld JSON-LD CompletedActionStatus",
|
||||
site: "lightnovelworld",
|
||||
seriesURL: lnwURL,
|
||||
body: lnwCompletedFixture,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "lightnovelworld ActiveActionStatus is not completed",
|
||||
site: "lightnovelworld",
|
||||
seriesURL: "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/",
|
||||
body: lnwOngoingFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "lightnovelworld with creativeWorkStatus removed",
|
||||
site: "lightnovelworld", seriesURL: lnwURL,
|
||||
body: strings.ReplaceAll(lnwCompletedFixture, `"creativeWorkStatus": "https://schema.org/CompletedActionStatus"`, ""),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "lightnovelworld challenge page",
|
||||
site: "lightnovelworld", seriesURL: lnwURL, body: challengeFixture,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unknown site",
|
||||
site: "mangadex", seriesURL: "https://mangadex.org/title/x", body: asuraCompletedFixture,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := siteCompletedFrom(tt.site, tt.seriesURL, tt.body); got != tt.want {
|
||||
t.Errorf("siteCompletedFrom(%q, %q, body) = %v, want %v", tt.site, tt.seriesURL, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Package notify posts owner-notice embeds to a Discord webhook. It is
|
||||
// deliberately small and stdlib-only: one POST of a JSON body needs no
|
||||
// Discord library, and the path imports nothing of this repo's session or
|
||||
// OAuth packages — that independence is why a webhook was chosen over a bot
|
||||
// (issue #171, AC9).
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/latest"
|
||||
)
|
||||
|
||||
// dangerColor is the dark design branch's --danger, #cf5c4d = 13589581. The
|
||||
// integer is unreadable, so a future edit will otherwise "fix" it — do not:
|
||||
// --ember means new chapter only, and a fault wearing ember would tell the
|
||||
// owner a stall is a release. This is the one colour a fault wears.
|
||||
const dangerColor = 13589581
|
||||
|
||||
// Client posts owner notices to one Discord webhook. The webhook address is a
|
||||
// secret in the class of TOKEN_KEY: it is never logged, never rendered, and
|
||||
// never carried in a returned error, which the poller logs.
|
||||
type Client struct {
|
||||
webhookURL string
|
||||
baseURL string // the deployment's public origin; embed URLs resolve against it
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New returns a Client posting to webhookURL. baseURL is the deployment's
|
||||
// public origin (Config.PublicBaseURL); the embed's deep-linked title is
|
||||
// built from it.
|
||||
func New(webhookURL, baseURL string) *Client {
|
||||
return &Client{
|
||||
webhookURL: webhookURL,
|
||||
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Notify posts one owner notice as a Discord embed: the danger colour, the
|
||||
// subject as a deep-linked title, the sentence as the description, the pass
|
||||
// time as the timestamp and the machine word in the footer. The send is
|
||||
// wrapped in a deadline so a hanging Discord cannot hold a poller Lane. On
|
||||
// failure the error carries no part of the webhook address (the poller logs
|
||||
// it), and the caller leaves the suppression row unwritten so the next pass
|
||||
// retries while the condition holds.
|
||||
func (c *Client) Notify(ctx context.Context, f latest.Fault, sentence, href string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
body, err := json.Marshal(c.payload(f, sentence, href))
|
||||
if err != nil {
|
||||
return fmt.Errorf("owner notice: marshal: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.webhookURL, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return errors.New("owner notice: build request")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
// The transport error embeds the webhook address; the address is a
|
||||
// secret in the class of TOKEN_KEY and the poller logs this error.
|
||||
return errors.New("owner notice: send failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Cap the read: a Discord error page is enough, and draining the body
|
||||
// lets the connection be reused.
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return fmt.Errorf("owner notice: webhook status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// payload is the webhook body: one embed and nothing else. No fields, no
|
||||
// thumbnail, no author block — the wire shape is what Discord reads.
|
||||
func (c *Client) payload(f latest.Fault, sentence, href string) webhookPayload {
|
||||
return webhookPayload{Embeds: []embed{{
|
||||
Color: dangerColor,
|
||||
Title: subject(f),
|
||||
URL: c.baseURL + href,
|
||||
Description: sentence,
|
||||
Timestamp: time.UnixMilli(f.Since).UTC().Format(time.RFC3339),
|
||||
Footer: embedFooter{Text: f.Condition},
|
||||
}}}
|
||||
}
|
||||
|
||||
// subject renders the embed's title: the Site the fault is about, with the
|
||||
// machine word so the title needs no per-condition wording here — #172's
|
||||
// conditions pass different sentences, not a different builder. For a fault
|
||||
// that is not one Site's, the machine word stands alone.
|
||||
func subject(f latest.Fault) string {
|
||||
if f.Site == "" {
|
||||
return f.Condition
|
||||
}
|
||||
return f.Site + ": " + f.Condition
|
||||
}
|
||||
|
||||
type webhookPayload struct {
|
||||
Embeds []embed `json:"embeds"`
|
||||
}
|
||||
|
||||
type embed struct {
|
||||
Color int `json:"color"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Footer embedFooter `json:"footer"`
|
||||
}
|
||||
|
||||
type embedFooter struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package notify_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/latest"
|
||||
"bookmarkmanager/backend/internal/notify"
|
||||
)
|
||||
|
||||
// TestNotifyEmbedShape pins the wire shape Discord actually reads: one embed
|
||||
// in the danger colour with the subject as a deep-linked title built from the
|
||||
// base URL, the sentence as the description, the pass time as an RFC3339
|
||||
// timestamp, the machine word in the footer, and no fields grid (nor
|
||||
// thumbnail, nor author block) at all. The webhook is a local server, so no
|
||||
// test can reach Discord.
|
||||
func TestNotifyEmbedShape(t *testing.T) {
|
||||
var body map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Errorf("decode request body: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
passTime := time.UnixMilli(5_000_000).UTC()
|
||||
c := notify.New(srv.URL, "https://bookmarks.test/")
|
||||
err := c.Notify(context.Background(),
|
||||
latest.Fault{Condition: latest.ConditionStall, Site: "comix", Since: passTime.UnixMilli()},
|
||||
"sentence", "/admin/lanes")
|
||||
if err != nil {
|
||||
t.Fatalf("Notify: %v", err)
|
||||
}
|
||||
|
||||
embeds, ok := body["embeds"].([]any)
|
||||
if !ok || len(embeds) != 1 {
|
||||
t.Fatalf("embeds = %#v, want exactly one embed", body["embeds"])
|
||||
}
|
||||
embed, ok := embeds[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("embed = %#v, want an object", embeds[0])
|
||||
}
|
||||
if color, ok := embed["color"].(float64); !ok || int(color) != 13589581 {
|
||||
t.Fatalf("color = %#v, want 13589581 (--danger #cf5c4d)", embed["color"])
|
||||
}
|
||||
if url := embed["url"]; url != "https://bookmarks.test/admin/lanes" {
|
||||
t.Fatalf("url = %#v, want the title deep-linked off the base URL", url)
|
||||
}
|
||||
if desc := embed["description"]; desc != "sentence" {
|
||||
t.Fatalf("description = %#v, want the sentence", desc)
|
||||
}
|
||||
footer, ok := embed["footer"].(map[string]any)
|
||||
if !ok || footer["text"] != latest.ConditionStall {
|
||||
t.Fatalf("footer = %#v, want the machine word in the footer", embed["footer"])
|
||||
}
|
||||
ts, ok := embed["timestamp"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("timestamp = %#v, want an RFC3339 string", embed["timestamp"])
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, ts)
|
||||
if err != nil || !parsed.Equal(passTime) {
|
||||
t.Fatalf("timestamp = %q, want %s (the pass time, RFC3339)", ts, passTime.Format(time.RFC3339))
|
||||
}
|
||||
for _, banned := range []string{"fields", "thumbnail", "author"} {
|
||||
if _, ok := embed[banned]; ok {
|
||||
t.Fatalf("embed has %q, want it absent (no field grid, no thumbnail, no author block)", banned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A non-2xx answer is an error the caller logs, and the error never carries
|
||||
// the webhook address — a secret in the class of TOKEN_KEY, and the poller
|
||||
// logs every notify error.
|
||||
func TestNotifyNon2xxIsErrorWithoutTheAddress(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := notify.New(srv.URL, "https://bookmarks.test")
|
||||
err := c.Notify(context.Background(),
|
||||
latest.Fault{Condition: latest.ConditionStall, Site: "comix", Since: 5_000_000},
|
||||
"sentence", "/admin/lanes")
|
||||
if err == nil {
|
||||
t.Fatal("Notify = nil, want an error for a 500")
|
||||
}
|
||||
if strings.Contains(err.Error(), srv.URL) {
|
||||
t.Fatalf("error %q leaks the webhook address", err)
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,9 @@ func Main(m *testing.M) int {
|
||||
fmt.Println("pgtest:", err)
|
||||
return 1
|
||||
}
|
||||
defer exec.Command("docker", "rm", "-f", id).Run()
|
||||
// -v: the postgres image declares a VOLUME, so an explicit rm without it
|
||||
// leaves the anonymous data volume behind on every test run.
|
||||
defer exec.Command("docker", "rm", "-f", "-v", id).Run()
|
||||
|
||||
adminURL = url
|
||||
return m.Run()
|
||||
@@ -85,7 +87,7 @@ func start() (id, url string, err error) {
|
||||
|
||||
port, err := exec.Command("docker", "port", id, "5432/tcp").Output()
|
||||
if err != nil {
|
||||
exec.Command("docker", "rm", "-f", id).Run()
|
||||
exec.Command("docker", "rm", "-f", "-v", id).Run()
|
||||
return "", "", fmt.Errorf("docker port: %w", err)
|
||||
}
|
||||
// "0.0.0.0:32768" (and possibly a second, IPv6 line); the port is all we want.
|
||||
@@ -94,7 +96,7 @@ func start() (id, url string, err error) {
|
||||
first[strings.LastIndex(first, ":")+1:])
|
||||
|
||||
if err := waitReady(url); err != nil {
|
||||
exec.Command("docker", "rm", "-f", id).Run()
|
||||
exec.Command("docker", "rm", "-f", "-v", id).Run()
|
||||
return "", "", err
|
||||
}
|
||||
return id, url, nil
|
||||
|
||||
@@ -9,32 +9,38 @@ import (
|
||||
|
||||
// Series filter names (issue #140): the seven repair filters are ordered
|
||||
// permanent-then-fixable — the repairs nothing will ever undo first, the
|
||||
// ones a Poll can make right after. SeriesFilterFinished is not part of
|
||||
// that ordering: a finished Series is a deliberate state, not a repair, so
|
||||
// it sits last, informational. A name is the repair a row needs, not the
|
||||
// SQL that finds it; the values are the wire form the Series list URL
|
||||
// carries (#142). "all" is the absent and unknown case: every Series.
|
||||
// ones a Poll can make right after. SeriesFilterFinished and
|
||||
// SeriesFilterSiteCompleted are not part of that ordering: a finished
|
||||
// Series is a deliberate state and a Site-completed one is the Site's own
|
||||
// marker, not repairs, so the pair rides the tail, informational. A name is
|
||||
// the repair a row needs, not the SQL that finds it; the values are the
|
||||
// wire form the Series list URL carries (#142). "all" is the absent and
|
||||
// unknown case: every Series.
|
||||
const (
|
||||
SeriesFilterAll = "all"
|
||||
SeriesFilterNoURL = "no_series_url"
|
||||
SeriesFilterNoChapter = "never_read_a_chapter"
|
||||
SeriesFilterNoReaders = "no_readers"
|
||||
SeriesFilterNeverChecked = "never_checked"
|
||||
SeriesFilterStale = "stale"
|
||||
SeriesFilterNoCover = "no_cover"
|
||||
SeriesFilterReaderReport = "reader_report"
|
||||
SeriesFilterFinished = "finished"
|
||||
SeriesFilterAll = "all"
|
||||
SeriesFilterNoURL = "no_series_url"
|
||||
SeriesFilterNoChapter = "never_read_a_chapter"
|
||||
SeriesFilterNoReaders = "no_readers"
|
||||
SeriesFilterNeverChecked = "never_checked"
|
||||
SeriesFilterStale = "stale"
|
||||
SeriesFilterNoCover = "no_cover"
|
||||
SeriesFilterReaderReport = "reader_report"
|
||||
SeriesFilterFinished = "finished"
|
||||
SeriesFilterSiteCompleted = "site_completed"
|
||||
SeriesFilterFailing = "failing"
|
||||
SeriesFilterUnverified = "unverified"
|
||||
)
|
||||
|
||||
// SeriesFilter is one named filter predicate over the whole library. Site
|
||||
// and Kind narrow the row read; Name picks the predicate; Cutoff is the
|
||||
// staleness boundary the "stale" filter compares against, supplied by the
|
||||
// caller's clock — the store has no clock; Page is 1-based.
|
||||
// staleness boundary the age-based filters — "stale" and the failing pair —
|
||||
// compare against, supplied by the caller's clock — the store has no clock;
|
||||
// Page is 1-based.
|
||||
type SeriesFilter struct {
|
||||
Site string // "" = every Site
|
||||
Kind string // "" = both library buckets' series
|
||||
Name string // one of the SeriesFilter* constants; "" = SeriesFilterAll
|
||||
Cutoff int64 // unix ms; "stale" reads it, the store never does
|
||||
Cutoff int64 // unix ms; the age-based filters read it, the store never does
|
||||
Page int // 1-based page of the row read; default 1
|
||||
}
|
||||
|
||||
@@ -45,7 +51,7 @@ type SeriesFilter struct {
|
||||
// and only the anonymous boolean in raisedByReaderAnswer crosses it.
|
||||
const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address,
|
||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at,
|
||||
s.latest_corrected_at, s.finished_at`
|
||||
s.latest_corrected_at, s.finished_at, s.site_completed_at`
|
||||
|
||||
// raisedByReaderAnswer answers "did a Reader's report set this number" without
|
||||
// naming which Reader. Kept apart from adminSeriesColumns so the column list —
|
||||
@@ -54,6 +60,12 @@ const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_
|
||||
// Reader behind them.
|
||||
const raisedByReaderAnswer = `(s.latest_raised_by IS NOT NULL) AS raised_by_reader`
|
||||
|
||||
// failureAnswer is the poll-failures row's answer (issue #165), kept apart
|
||||
// from adminSeriesColumns like raisedByReaderAnswer: outcome and failing_since
|
||||
// are not Series columns, and the COALESCE keeps the row scannable when the
|
||||
// LEFT JOIN finds no failure row.
|
||||
const failureAnswer = `COALESCE(f.outcome, ''), COALESCE(f.failing_since, 0)`
|
||||
|
||||
// seriesPageSize is the row read's page length. The tie-break in the query's
|
||||
// ORDER BY is what makes this a stable page boundary — see SeriesPage.
|
||||
const seriesPageSize = 50
|
||||
@@ -83,9 +95,19 @@ type AdminSeries struct {
|
||||
LatestCorrectedAt int64
|
||||
ReaderCount int
|
||||
RaisedByReader bool // a Reader's report set LatestChapterNum
|
||||
// FailureOutcome is the outcome word of the Series' standing failure, ""
|
||||
// when no failure row stands. FailingSince is when the run of failures
|
||||
// began, zero with no row. Both come from the LEFT JOIN, not the Series
|
||||
// row (issue #165: the row's existence is the state).
|
||||
FailureOutcome string
|
||||
FailingSince int64
|
||||
// FinishedAt is the owner's finish stamp: unix ms, zero while the Series is
|
||||
// not finished — the same shape as the Correction stamp, and its own undo.
|
||||
FinishedAt int64
|
||||
// SiteCompletedAt is when the last successful Poll read saw the Site's own
|
||||
// completed value, zero meaning it did not (issue #168). The Site's marker,
|
||||
// never a Lifecycle decision: the owner's Finish is the only retirement.
|
||||
SiteCompletedAt int64
|
||||
}
|
||||
|
||||
// SeriesPage is one page of the owner's filtered Series list plus the count
|
||||
@@ -119,17 +141,26 @@ func (a AdminSeries) Key() string { return a.Site + ":" + a.SeriesID }
|
||||
// gate case is invisible to SQL, needs the Site registry in Go, and belongs to
|
||||
// a later repair), never-read-a-chapter and never-checked as disjoint halves
|
||||
// (non-zero versus zero check stamp), stale, no cover, finished (the
|
||||
// retirement stamp, read directly), and Reader-report.
|
||||
// retirement stamp, read directly), site completed (the Site's marker, read
|
||||
// directly), Reader-report, and the failing pair — failing (the failure row
|
||||
// exists, a chapter exists, and failing_since is past the cutoff) and
|
||||
// unverified (the Reader-attributed subset of failing).
|
||||
// failing's chapter IS NOT NULL is the exact complement of never-read-a-
|
||||
// chapter's IS NULL half, so the two are disjoint by construction.
|
||||
// no_readers is the one HAVING predicate: it is the orphan test, an aggregate
|
||||
// over the LEFT JOIN, where a bare WHERE has no row to test.
|
||||
//
|
||||
// The clock-versus-outcome split decides which predicates exclude finished
|
||||
// Series (`s.finished_at = 0` in each of the four): the clock-driven one —
|
||||
// Series (`s.finished_at = 0` in each of the five): the clock-driven ones —
|
||||
// never-checked, stale, no-chapter, no-cover — keep ticking after the last
|
||||
// Poll, so they would report a retired row as a problem no Poll is coming to
|
||||
// fix; the three outcome-driven ones — no-URL, no-readers, Reader-report —
|
||||
// read stored facts that simply stop arriving, so a finished Series needing a
|
||||
// genuine repair still shows up under them.
|
||||
// fix; site-completed's stamp is the Site's, and it keeps standing after the
|
||||
// owner retires the row, so a finished Series would be reported as work
|
||||
// nobody is going to do. The outcome-driven ones — no-URL, no-readers,
|
||||
// Reader-report, failing, unverified — read stored facts that simply stop
|
||||
// arriving, so a finished Series needing a genuine repair still shows up
|
||||
// under them. The failing pair carries no finished guard for exactly that
|
||||
// contrast: a failure row is a stored outcome, not a ticking clock.
|
||||
//
|
||||
// stale is the checked-but-old half of the stamp partition — because the
|
||||
// verdict line wants "not checked in twelve hours" as one figure, and a never
|
||||
@@ -157,8 +188,16 @@ func adminFilter(f SeriesFilter) (where, having string, args []any, err error) {
|
||||
clauses = append(clauses, `s.cover_address = '' AND s.finished_at = 0`)
|
||||
case SeriesFilterReaderReport:
|
||||
clauses = append(clauses, `s.latest_raised_by IS NOT NULL`)
|
||||
case SeriesFilterFailing:
|
||||
clauses = append(clauses, `f.site IS NOT NULL AND s.latest_chapter_num IS NOT NULL AND f.failing_since < $`+strconv.Itoa(len(args)+1))
|
||||
args = append(args, f.Cutoff)
|
||||
case SeriesFilterUnverified:
|
||||
clauses = append(clauses, `f.site IS NOT NULL AND s.latest_chapter_num IS NOT NULL AND f.failing_since < $`+strconv.Itoa(len(args)+1)+` AND s.latest_raised_by IS NOT NULL`)
|
||||
args = append(args, f.Cutoff)
|
||||
case SeriesFilterFinished:
|
||||
clauses = append(clauses, `s.finished_at > 0`)
|
||||
case SeriesFilterSiteCompleted:
|
||||
clauses = append(clauses, `s.site_completed_at > 0 AND s.finished_at = 0`)
|
||||
case SeriesFilterNoReaders:
|
||||
having = `HAVING COUNT(b.reader_id) = 0`
|
||||
default:
|
||||
@@ -204,15 +243,18 @@ func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) {
|
||||
base := len(args)
|
||||
args = append(args, seriesPageSize, seriesPageSize*(f.Page-1))
|
||||
rows, err := s.db.Query(`
|
||||
SELECT `+adminSeriesColumns+`, `+raisedByReaderAnswer+`,
|
||||
SELECT `+adminSeriesColumns+`, `+raisedByReaderAnswer+`, `+failureAnswer+`,
|
||||
COUNT(b.reader_id) AS reader_count,
|
||||
COUNT(*) OVER () AS filtered_total
|
||||
FROM series s
|
||||
LEFT JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
|
||||
LEFT JOIN poll_failures f ON f.site = s.site AND f.series_id = s.series_id
|
||||
`+where+`
|
||||
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address,
|
||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
|
||||
s.force_poll_at, s.latest_corrected_at, s.finished_at, s.latest_raised_by
|
||||
s.force_poll_at, s.latest_corrected_at, s.finished_at, s.site_completed_at,
|
||||
s.latest_raised_by,
|
||||
f.outcome, f.failing_since
|
||||
`+having+`
|
||||
ORDER BY s.latest_checked_at, s.site, s.series_id
|
||||
LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...)
|
||||
@@ -251,6 +293,7 @@ func (s *Store) SeriesShapes(f SeriesFilter) ([]SiteSeriesShape, error) {
|
||||
SELECT s.site, s.kind
|
||||
FROM series s
|
||||
LEFT JOIN bookmarks b ON b.site = s.site AND b.series_id = s.series_id
|
||||
LEFT JOIN poll_failures f ON f.site = s.site AND f.series_id = s.series_id
|
||||
`+where+`
|
||||
GROUP BY s.site, s.series_id, s.kind
|
||||
`+having+`
|
||||
@@ -273,11 +316,11 @@ func (s *Store) SeriesShapes(f SeriesFilter) ([]SiteSeriesShape, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// scanAdminSeries reads one row in adminSeriesColumns + raisedByReaderAnswer
|
||||
// order, plus the query's reader_count and filtered_total columns, and returns
|
||||
// the window total alongside the row. latest_chapter_num is NULL until first
|
||||
// captured — the "never read a chapter" state. The Sighting-raiser column is
|
||||
// never among the scanned columns.
|
||||
// scanAdminSeries reads one row in adminSeriesColumns + raisedByReaderAnswer +
|
||||
// failureAnswer order, plus the query's reader_count and filtered_total
|
||||
// columns, and returns the window total alongside the row. latest_chapter_num
|
||||
// is NULL until first captured — the "never read a chapter" state. The
|
||||
// Sighting-raiser column is never among the scanned columns.
|
||||
func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) {
|
||||
var (
|
||||
a AdminSeries
|
||||
@@ -287,8 +330,8 @@ func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) {
|
||||
if err := scan(
|
||||
&a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress,
|
||||
&a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt,
|
||||
&a.ForcePollAt, &a.LatestCorrectedAt, &a.FinishedAt,
|
||||
&a.RaisedByReader, &a.ReaderCount, &total,
|
||||
&a.ForcePollAt, &a.LatestCorrectedAt, &a.FinishedAt, &a.SiteCompletedAt,
|
||||
&a.RaisedByReader, &a.FailureOutcome, &a.FailingSince, &a.ReaderCount, &total,
|
||||
); err != nil {
|
||||
return AdminSeries{}, 0, err
|
||||
}
|
||||
|
||||
@@ -12,14 +12,15 @@ import (
|
||||
// Upsert path could not produce together: an orphan has no bookmark, and a
|
||||
// Reader-raised Latest Chapter needs a Sighting the store does not create.
|
||||
type seriesSeed struct {
|
||||
key string
|
||||
kind string
|
||||
url string
|
||||
cover string // cover_address
|
||||
checkedAt int64
|
||||
latestNum *float64
|
||||
bookmarks int // readers that hold it; 0 = orphan
|
||||
raisedBy bool // a Reader's report is attributed as the raiser
|
||||
key string
|
||||
kind string
|
||||
url string
|
||||
cover string // cover_address
|
||||
checkedAt int64
|
||||
latestNum *float64
|
||||
bookmarks int // readers that hold it; 0 = orphan
|
||||
raisedBy bool // a Reader's report is attributed as the raiser
|
||||
siteCompletedAt int64 // the Site's own marker (issue #168), 0 = not set
|
||||
}
|
||||
|
||||
// seedAdminSeries inserts one series row and its bookmarks (owner first, then
|
||||
@@ -39,10 +40,10 @@ func seedAdminSeries(t *testing.T, s *Store, seed seriesSeed) {
|
||||
}
|
||||
if _, err := s.db.Exec(`
|
||||
INSERT INTO series (site, series_id, title, kind, series_url, cover_address,
|
||||
latest_checked_at, latest_chapter, latest_chapter_num)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
latest_checked_at, latest_chapter, latest_chapter_num, site_completed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
site, seriesID, "Title of "+seed.key, seed.kind, seed.url, seed.cover,
|
||||
seed.checkedAt, latestChapter, seed.latestNum); err != nil {
|
||||
seed.checkedAt, latestChapter, seed.latestNum, seed.siteCompletedAt); err != nil {
|
||||
t.Fatalf("seed series %q: %v", seed.key, err)
|
||||
}
|
||||
for i := range seed.bookmarks {
|
||||
@@ -527,3 +528,209 @@ func TestAdminSeriesCarriesFinishedAt(t *testing.T) {
|
||||
t.Fatalf("row = %+v, want FinishedAt 5000", page.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
// The failing pair reads the failure row's age, not the Series row (issue
|
||||
// #165): failing requires the joined row, a Latest Chapter, and failing_since
|
||||
// past the cutoff — the same constant stale reads; unverified is the
|
||||
// Reader-attributed subset of the same test. A failure inside the window is
|
||||
// in neither — one bad fetch is not a fault to correct — and a Series that
|
||||
// never captured a chapter is never failing, the exact complement of
|
||||
// never-read-a-chapter's IS NULL half, so the two filters are disjoint by
|
||||
// construction. A finished failing Series still appears: this pair reads
|
||||
// stored outcomes, which simply stop arriving, and carries no finished guard.
|
||||
func TestAdminFailingAndUnverifiedFilters(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
const cutoff = 5000
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:failing", url: "u", cover: "c", checkedAt: 9000, latestNum: new(10.0), bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:recent", url: "u", cover: "c", checkedAt: 9000, latestNum: new(9.0), bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:nochapter", url: "u", cover: "c", checkedAt: 9000, bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:reader", url: "u", cover: "c", checkedAt: 9000, latestNum: new(8.0), bookmarks: 1, raisedBy: true})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:polled", url: "u", cover: "c", checkedAt: 9000, latestNum: new(7.0), bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:fin-failing", url: "u", cover: "c", checkedAt: 9000, latestNum: new(6.0), bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:healthy", url: "u", cover: "c", checkedAt: 9000, latestNum: new(5.0), bookmarks: 1})
|
||||
|
||||
// Failure rows seed the run's start stamp; the cutoff decides the age.
|
||||
for _, f := range []struct {
|
||||
key, word string
|
||||
since int64
|
||||
}{
|
||||
{"asura:failing", "not_found", 2000},
|
||||
{"asura:recent", "not_found", 9000},
|
||||
{"asura:nochapter", "not_found", 2000},
|
||||
{"asura:reader", "not_found", 2000},
|
||||
{"asura:polled", "errors", 2000},
|
||||
{"asura:fin-failing", "not_found", 2000},
|
||||
} {
|
||||
site, id, _ := strings.Cut(f.key, ":")
|
||||
if err := s.RecordSeriesFailure(site, id, f.word, f.since); err != nil {
|
||||
t.Fatalf("seed failure %s: %v", f.key, err)
|
||||
}
|
||||
}
|
||||
if err := s.SetSeriesFinished("asura", "fin-failing", 1000); err != nil {
|
||||
t.Fatalf("finish asura:fin-failing: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
f SeriesFilter
|
||||
want []string
|
||||
}{
|
||||
{"failing", SeriesFilter{Name: SeriesFilterFailing, Cutoff: cutoff}, []string{"asura:failing", "asura:reader", "asura:polled", "asura:fin-failing"}},
|
||||
{"unverified", SeriesFilter{Name: SeriesFilterUnverified, Cutoff: cutoff}, []string{"asura:reader"}},
|
||||
{"never read a chapter", SeriesFilter{Name: SeriesFilterNoChapter}, []string{"asura:nochapter"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := pageKeys(t, s, tc.f)
|
||||
want := map[string]bool{}
|
||||
for _, k := range tc.want {
|
||||
want[k] = true
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%+v returned %v, want exactly %v", tc.f, got, want)
|
||||
}
|
||||
for k := range want {
|
||||
if !got[k] {
|
||||
t.Fatalf("%+v dropped %q (got %v)", tc.f, k, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Disjointness: the failing pair and never-read-a-chapter are disjoint by
|
||||
// the chapter column — IS NOT NULL here, IS NULL there — so no row may be
|
||||
// counted under both.
|
||||
failing := pageKeys(t, s, SeriesFilter{Name: SeriesFilterFailing, Cutoff: cutoff})
|
||||
noChapter := pageKeys(t, s, SeriesFilter{Name: SeriesFilterNoChapter})
|
||||
for k := range failing {
|
||||
if noChapter[k] {
|
||||
t.Fatalf("row %q matches both failing and never-read-a-chapter", k)
|
||||
}
|
||||
}
|
||||
if failing["asura:nochapter"] {
|
||||
t.Fatal("a Series with no chapter ever captured appears in failing")
|
||||
}
|
||||
if !noChapter["asura:nochapter"] {
|
||||
t.Fatal("the no-chapter Series vanished from never-read-a-chapter")
|
||||
}
|
||||
|
||||
// The aggregates count the same rows the lists do: the landing page's
|
||||
// figures and the select's options come from these two passes.
|
||||
for _, name := range []string{SeriesFilterFailing, SeriesFilterUnverified} {
|
||||
shapes, err := s.SeriesShapes(SeriesFilter{Name: name, Cutoff: cutoff})
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesShapes(%s): %v", name, err)
|
||||
}
|
||||
sum := 0
|
||||
for _, sh := range shapes {
|
||||
sum += sh.Total
|
||||
}
|
||||
want := len(pageKeys(t, s, SeriesFilter{Name: name, Cutoff: cutoff}))
|
||||
if sum != want {
|
||||
t.Fatalf("%s aggregate sum = %d, want %d (aggregate disagrees with row query)", name, sum, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The projection carries the failure facts from the LEFT JOIN, not the
|
||||
// Series row: a failing Series reads back its outcome word and the stamp its
|
||||
// run began at; a Series with no failure row reads empty and zero. Nothing
|
||||
// new is projected from the Series row itself.
|
||||
func TestAdminFailureProjection(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:broken", url: "u", cover: "c", checkedAt: 9000, latestNum: new(9.0), bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:fine", url: "u", cover: "c", checkedAt: 9000, latestNum: new(8.0), bookmarks: 1})
|
||||
if err := s.RecordSeriesFailure("asura", "broken", "not_found", 2000); err != nil {
|
||||
t.Fatalf("seed failure row: %v", err)
|
||||
}
|
||||
|
||||
page, err := s.SeriesPage(SeriesFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesPage: %v", err)
|
||||
}
|
||||
byKey := map[string]AdminSeries{}
|
||||
for _, a := range page.Rows {
|
||||
byKey[a.Key()] = a
|
||||
}
|
||||
if byKey["asura:broken"].FailureOutcome != "not_found" || byKey["asura:broken"].FailingSince != 2000 {
|
||||
t.Fatalf("broken row = %+v, want FailureOutcome not_found, FailingSince 2000", byKey["asura:broken"])
|
||||
}
|
||||
if byKey["asura:fine"].FailureOutcome != "" || byKey["asura:fine"].FailingSince != 0 {
|
||||
t.Fatalf("fine row = %+v, want empty outcome and zero stamp", byKey["asura:fine"])
|
||||
}
|
||||
}
|
||||
|
||||
// The site-completed filter (issue #170) lists the Site's own marker as work
|
||||
// to work through, carrying the same finished guard the clock-driven
|
||||
// predicates gained: the Site's stamp keeps standing after the owner retires
|
||||
// the row, so a finished Series would be reported as work nobody is going to
|
||||
// do. A zero stamp never appears. Un-finishing puts the Series back in the
|
||||
// Poll query — the finished_at gate is #157's own, asserted through
|
||||
// DueForLatestCheck rather than re-derived here.
|
||||
func TestAdminSiteCompletedFilter(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:done", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1, siteCompletedAt: 5000})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:never", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:retired", url: "u", cover: "c", checkedAt: 0, latestNum: new(4.0), bookmarks: 1, siteCompletedAt: 5000})
|
||||
seedAdminSeries(t, s, seriesSeed{key: "asura:healthy", url: "u", cover: "c", checkedAt: 9000, latestNum: new(4.0), bookmarks: 1})
|
||||
if err := s.SetSeriesFinished("asura", "retired", 1000); err != nil {
|
||||
t.Fatalf("finish asura:retired: %v", err)
|
||||
}
|
||||
|
||||
got := pageKeys(t, s, SeriesFilter{Name: SeriesFilterSiteCompleted})
|
||||
if len(got) != 1 || !got["asura:done"] {
|
||||
t.Fatalf("site-completed returned %v, want only asura:done", got)
|
||||
}
|
||||
|
||||
// The projection carries the stamp so the detail page can age it.
|
||||
page, err := s.SeriesPage(SeriesFilter{Name: SeriesFilterSiteCompleted})
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesPage(site_completed): %v", err)
|
||||
}
|
||||
if len(page.Rows) != 1 || page.Rows[0].SiteCompletedAt != 5000 {
|
||||
t.Fatalf("row = %+v, want SiteCompletedAt 5000", page.Rows)
|
||||
}
|
||||
|
||||
// The aggregate counts the same row: the landing figure and the select's
|
||||
// option come from this pass, so they cannot disagree with the list.
|
||||
shapes, err := s.SeriesShapes(SeriesFilter{Name: SeriesFilterSiteCompleted})
|
||||
if err != nil {
|
||||
t.Fatalf("SeriesShapes(site_completed): %v", err)
|
||||
}
|
||||
sum := 0
|
||||
for _, sh := range shapes {
|
||||
sum += sh.Total
|
||||
}
|
||||
if sum != 1 {
|
||||
t.Fatalf("site-completed aggregate = %d, want 1", sum)
|
||||
}
|
||||
|
||||
// Un-finishing puts the Series back in the Poll query: the due read's
|
||||
// finished_at gate (issue #157) admits it again — asserted through the
|
||||
// Lane's own read, not re-derived here.
|
||||
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck: %v", err)
|
||||
}
|
||||
for _, sr := range due {
|
||||
if sr.Key() == "asura:retired" {
|
||||
t.Fatalf("a finished Series is still due for a Poll:\n%+v", due)
|
||||
}
|
||||
}
|
||||
if err := s.SetSeriesFinished("asura", "retired", 0); err != nil {
|
||||
t.Fatalf("un-finish asura:retired: %v", err)
|
||||
}
|
||||
due, err = s.DueForLatestCheck("asura", 1000, noCeiling)
|
||||
if err != nil {
|
||||
t.Fatalf("DueForLatestCheck after un-finish: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, sr := range due {
|
||||
if sr.Key() == "asura:retired" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("un-finished Series is not due for a Poll:\n%+v", due)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- A 4xx other than the 403 refusal is the page being gone, not the Site being
|
||||
-- unwell; the pass log counts it separately so the Lanes page can say "not
|
||||
-- found" (#164). DEFAULT 0 keeps pre-existing rows readable.
|
||||
ALTER TABLE poll_passes ADD COLUMN not_found integer NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- One row per Series that is failing right now (ADR-0016): the row's
|
||||
-- existence is the failure state, failing_since ages the run of failures,
|
||||
-- and a correct read deletes the row. Keyed by the same (site, series_id)
|
||||
-- composite the rest of the system uses, with the cascade so deleting a
|
||||
-- Series takes its failure row and orphan removal stays a single statement.
|
||||
CREATE TABLE poll_failures (
|
||||
site text NOT NULL,
|
||||
series_id text NOT NULL,
|
||||
outcome text NOT NULL,
|
||||
failing_since bigint NOT NULL,
|
||||
PRIMARY KEY (site, series_id),
|
||||
FOREIGN KEY (site, series_id) REFERENCES series (site, series_id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- When the last successful Poll read saw the Site's own completed value
|
||||
-- (#168): epoch-ms, zero meaning it did not. DEFAULT 0 keeps pre-existing
|
||||
-- rows readable.
|
||||
ALTER TABLE series ADD COLUMN site_completed_at bigint NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Owner notices (issue #171): one row per episode, remembered only as "the
|
||||
-- owner was told". The row's presence is the whole state — the poller checks
|
||||
-- it before sending, writes it after a successful send, and clears it when
|
||||
-- the condition no longer holds. site is '' for a fault that is not one
|
||||
-- Site's; the composite primary key is what makes the row a lock against a
|
||||
-- second message for the same episode.
|
||||
CREATE TABLE owner_notices (
|
||||
condition text NOT NULL,
|
||||
site text NOT NULL,
|
||||
notified_at bigint NOT NULL,
|
||||
PRIMARY KEY (condition, site)
|
||||
);
|
||||
+195
-13
@@ -84,6 +84,10 @@ type Series struct {
|
||||
LatestChapter string
|
||||
LatestChapterNum *float64 // nil until first captured
|
||||
LatestCheckedAt int64 // unix ms; see MarkLatestChecked
|
||||
// SiteCompletedAt is when the last successful Poll read saw the Site's
|
||||
// own completed value, zero meaning it did not (issue #168). A poller
|
||||
// fact, not reading progress: Upsert never touches it.
|
||||
SiteCompletedAt int64
|
||||
// LatestRaisedBy is the Reader whose Sighting last raised LatestChapter,
|
||||
// and nil when the stored value is a Poll's own finding. It is what lets a
|
||||
// Poll that contradicts the value downwards name a Reader instead of
|
||||
@@ -110,7 +114,7 @@ type LanePass struct {
|
||||
GapMS int64
|
||||
Clamped bool
|
||||
Refused, Unreachable, NoChapter int
|
||||
Unfetchable, Errors int
|
||||
Unfetchable, Errors, NotFound int
|
||||
PausedUntil, RefuseUntil int64
|
||||
}
|
||||
|
||||
@@ -119,7 +123,7 @@ type LanePass struct {
|
||||
type SiteOutcomes struct {
|
||||
Site string
|
||||
Refused, Unreachable, NoChapter int
|
||||
Unfetchable, Errors int
|
||||
Unfetchable, Errors, NotFound int
|
||||
}
|
||||
|
||||
// LanePause is one persisted Lane pause stamp.
|
||||
@@ -240,10 +244,11 @@ const bookmarkColumns = `b.site, b.series_id, s.title, s.series_url, s.cover_add
|
||||
// due query. latest_checked_at lives only on series — see MarkLatestChecked
|
||||
// for why it stays off every client-visible write.
|
||||
const seriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover, s.cover_address,
|
||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.latest_raised_by`
|
||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.latest_raised_by,
|
||||
s.site_completed_at`
|
||||
|
||||
const lanePassColumns = `p.site, p.ran_at, p.skip, p.due, p.checked, p.gap_ms, p.clamped,
|
||||
p.refused, p.unreachable, p.no_chapter, p.unfetchable, p.errors,
|
||||
p.refused, p.unreachable, p.no_chapter, p.unfetchable, p.errors, p.not_found,
|
||||
COALESCE(l.paused_until, 0), COALESCE(l.refuse_until, 0)`
|
||||
|
||||
// Owner is the person running the service: the first Reader, seeded at startup
|
||||
@@ -646,7 +651,7 @@ func scanSeries(scan func(...any) error) (Series, error) {
|
||||
if err := scan(
|
||||
&sr.Site, &sr.SeriesID, &sr.Title, &sr.SeriesURL, &sr.Cover, &sr.CoverAddress,
|
||||
&sr.Kind, &sr.LatestChapter, &latestChapterNum, &sr.LatestCheckedAt, &latestRaisedBy,
|
||||
&sr.Forced, &sr.readerCount,
|
||||
&sr.SiteCompletedAt, &sr.Forced, &sr.readerCount,
|
||||
); err != nil {
|
||||
return Series{}, err
|
||||
}
|
||||
@@ -663,7 +668,7 @@ func scanLanePass(scan func(...any) error) (LanePass, error) {
|
||||
var p LanePass
|
||||
if err := scan(
|
||||
&p.Site, &p.RanAt, &p.Skip, &p.Due, &p.Checked, &p.GapMS, &p.Clamped,
|
||||
&p.Refused, &p.Unreachable, &p.NoChapter, &p.Unfetchable, &p.Errors,
|
||||
&p.Refused, &p.Unreachable, &p.NoChapter, &p.Unfetchable, &p.Errors, &p.NotFound,
|
||||
&p.PausedUntil, &p.RefuseUntil,
|
||||
); err != nil {
|
||||
return LanePass{}, err
|
||||
@@ -1131,10 +1136,10 @@ func (s *Store) RecordLanePass(p LanePass, retainBefore int64) error {
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO poll_passes
|
||||
(site, ran_at, skip, due, checked, gap_ms, clamped,
|
||||
refused, unreachable, no_chapter, unfetchable, errors)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||
refused, unreachable, no_chapter, unfetchable, errors, not_found)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
|
||||
p.Site, p.RanAt, p.Skip, p.Due, p.Checked, p.GapMS, p.Clamped,
|
||||
p.Refused, p.Unreachable, p.NoChapter, p.Unfetchable, p.Errors); err != nil {
|
||||
p.Refused, p.Unreachable, p.NoChapter, p.Unfetchable, p.Errors, p.NotFound); err != nil {
|
||||
return fmt.Errorf("insert lane pass %s at %d: %w", p.Site, p.RanAt, err)
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM poll_passes WHERE ran_at < $1`, retainBefore); err != nil {
|
||||
@@ -1171,7 +1176,7 @@ func (s *Store) LatestLanePasses() ([]LanePass, error) {
|
||||
FROM (
|
||||
SELECT DISTINCT ON (site)
|
||||
site, ran_at, skip, due, checked, gap_ms, clamped,
|
||||
refused, unreachable, no_chapter, unfetchable, errors
|
||||
refused, unreachable, no_chapter, unfetchable, errors, not_found
|
||||
FROM poll_passes
|
||||
ORDER BY site, ran_at DESC
|
||||
) p
|
||||
@@ -1198,7 +1203,7 @@ func (s *Store) LatestLanePasses() ([]LanePass, error) {
|
||||
func (s *Store) LanePassOutcomes(since int64) ([]SiteOutcomes, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT site, SUM(refused), SUM(unreachable), SUM(no_chapter),
|
||||
SUM(unfetchable), SUM(errors)
|
||||
SUM(unfetchable), SUM(errors), SUM(not_found)
|
||||
FROM poll_passes
|
||||
WHERE ran_at >= $1
|
||||
GROUP BY site
|
||||
@@ -1213,7 +1218,7 @@ func (s *Store) LanePassOutcomes(since int64) ([]SiteOutcomes, error) {
|
||||
var outcomes SiteOutcomes
|
||||
if err := rows.Scan(
|
||||
&outcomes.Site, &outcomes.Refused, &outcomes.Unreachable,
|
||||
&outcomes.NoChapter, &outcomes.Unfetchable, &outcomes.Errors,
|
||||
&outcomes.NoChapter, &outcomes.Unfetchable, &outcomes.Errors, &outcomes.NotFound,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan lane pass outcomes: %w", err)
|
||||
}
|
||||
@@ -1222,6 +1227,101 @@ func (s *Store) LanePassOutcomes(since int64) ([]SiteOutcomes, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RefusingSince reports, per Site, when that Site's current unbroken run of
|
||||
// refusing passes began: a pass refuses when the Lane gate returned early
|
||||
// (skip = 'refusing') or the pass loop counted refusals (refused > 0), so a
|
||||
// Site parked in refusal backoff keeps its run unbroken. A Site whose
|
||||
// latest pass did not refuse is absent. The run is bounded by the pass
|
||||
// log's retention — a run older than the log answers with the oldest row
|
||||
// present — and nothing after now counts: the cutoff is the caller's clock.
|
||||
func (s *Store) RefusingSince(now int64) (map[string]int64, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT site, MIN(ran_at)
|
||||
FROM poll_passes p
|
||||
WHERE ran_at <= $1
|
||||
AND (refused > 0 OR skip = 'refusing')
|
||||
AND ran_at > COALESCE((
|
||||
SELECT MAX(q.ran_at) FROM poll_passes q
|
||||
WHERE q.site = p.site AND q.ran_at <= $1
|
||||
AND NOT (q.refused > 0 OR q.skip = 'refusing')
|
||||
), 0)
|
||||
GROUP BY site`, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query refusing since: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := map[string]int64{}
|
||||
for rows.Next() {
|
||||
var site string
|
||||
var since int64
|
||||
if err := rows.Scan(&site, &since); err != nil {
|
||||
return nil, fmt.Errorf("scan refusing since: %w", err)
|
||||
}
|
||||
out[site] = since
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SidecarOK reports, per Site in sites, the unix ms of that Site's most
|
||||
// recent pass that actually reached the sidecar: the pass ran its loop
|
||||
// (skip = '') and no Series read lost Chrome (unreachable = 0) — a
|
||||
// challenge answer still reached the sidecar, a lost sidecar did not. A
|
||||
// Site with no such pass is absent: an asleep Lane never reached it, so it
|
||||
// is absent too and cannot age into a sidecar-down alarm by itself.
|
||||
func (s *Store) SidecarOK(sites []string) (map[string]int64, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT DISTINCT ON (site) site, ran_at
|
||||
FROM poll_passes
|
||||
WHERE site = ANY($1) AND skip = '' AND unreachable = 0
|
||||
ORDER BY site, ran_at DESC`, sites)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query sidecar ok: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := map[string]int64{}
|
||||
for rows.Next() {
|
||||
var site string
|
||||
var ranAt int64
|
||||
if err := rows.Scan(&site, &ranAt); err != nil {
|
||||
return nil, fmt.Errorf("scan sidecar ok: %w", err)
|
||||
}
|
||||
out[site] = ranAt
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// NoChapterShare reports, per Site, the share of that Site's Series holding
|
||||
// a no-chapter failure row older than cutoff: old rows over the Site's
|
||||
// whole Series count, so a four-Series Site and a 200-Series Site are
|
||||
// judged on the same scale. A Site with no Series has no share and is
|
||||
// absent.
|
||||
func (s *Store) NoChapterShare(cutoff int64) (map[string]float64, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT s.site,
|
||||
(COUNT(*) FILTER (WHERE f.outcome = 'no_chapter' AND f.failing_since < $1))::float8
|
||||
/ (COUNT(*)::float8)
|
||||
FROM series s
|
||||
LEFT JOIN poll_failures f ON f.site = s.site AND f.series_id = s.series_id
|
||||
GROUP BY s.site
|
||||
ORDER BY s.site`, cutoff)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query no-chapter share: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := map[string]float64{}
|
||||
for rows.Next() {
|
||||
var site string
|
||||
var share float64
|
||||
if err := rows.Scan(&site, &share); err != nil {
|
||||
return nil, fmt.Errorf("scan no-chapter share: %w", err)
|
||||
}
|
||||
out[site] = share
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
// SetLaneRefusal persists a Site's refusal backoff stamp without touching its
|
||||
// pause. until is supplied by the caller's clock.
|
||||
func (s *Store) SetLaneRefusal(site string, until int64) error {
|
||||
@@ -1298,6 +1398,72 @@ func (s *Store) LaneGates(site string) (pausedUntil, refuseUntil int64, err erro
|
||||
return pausedUntil, refuseUntil, nil
|
||||
}
|
||||
|
||||
// RecordSeriesFailure upserts one Series' failure row: the outcome word is
|
||||
// updated when it changes, failing_since is never overwritten, and an
|
||||
// unchanged word writes nothing. One statement, so the identical-word no-op
|
||||
// and the keep-the-stamp rule are the same guarantee: the SET arm fires only
|
||||
// when the word differs, and failing_since is simply absent from it
|
||||
// (ADR-0016).
|
||||
func (s *Store) RecordSeriesFailure(site, seriesID, outcome string, now int64) error {
|
||||
if _, err := s.db.Exec(`
|
||||
INSERT INTO poll_failures (site, series_id, outcome, failing_since) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (site, series_id) DO UPDATE SET outcome = EXCLUDED.outcome
|
||||
WHERE poll_failures.outcome <> EXCLUDED.outcome`, site, seriesID, outcome, now); err != nil {
|
||||
return fmt.Errorf("record series failure %s:%s: %w", site, seriesID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearSeriesFailure deletes one Series' failure row. A row that is not there
|
||||
// is not an error.
|
||||
func (s *Store) ClearSeriesFailure(site, seriesID string) error {
|
||||
if _, err := s.db.Exec(
|
||||
`DELETE FROM poll_failures WHERE site = $1 AND series_id = $2`, site, seriesID); err != nil {
|
||||
return fmt.Errorf("clear series failure %s:%s: %w", site, seriesID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NoticeSent reports whether the owner has already been told about this
|
||||
// episode (issue #171). The row's presence is the whole state, so a missing
|
||||
// row reads false without error.
|
||||
func (s *Store) NoticeSent(condition, site string) (bool, error) {
|
||||
var at int64
|
||||
err := s.db.QueryRow(
|
||||
`SELECT notified_at FROM owner_notices WHERE condition = $1 AND site = $2`,
|
||||
condition, site).Scan(&at)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("notice sent %s/%s: %w", condition, site, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// MarkNoticeSent records that the owner was told. Called only after a
|
||||
// successful send. Re-marking the same episode just moves the stamp: the row
|
||||
// is a lock against a second message, not a log.
|
||||
func (s *Store) MarkNoticeSent(condition, site string, at int64) error {
|
||||
if _, err := s.db.Exec(`
|
||||
INSERT INTO owner_notices (condition, site, notified_at) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (condition, site) DO UPDATE SET notified_at = EXCLUDED.notified_at`,
|
||||
condition, site, at); err != nil {
|
||||
return fmt.Errorf("mark notice sent %s/%s: %w", condition, site, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearNotice forgets an episode, so the condition's next occurrence sends
|
||||
// again. A row that is not there is not an error.
|
||||
func (s *Store) ClearNotice(condition, site string) error {
|
||||
if _, err := s.db.Exec(
|
||||
`DELETE FROM owner_notices WHERE condition = $1 AND site = $2`, condition, site); err != nil {
|
||||
return fmt.Errorf("clear notice %s/%s: %w", condition, site, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DueForLatestCheck returns one Site's series whose server-side
|
||||
// latest-chapter check has aged past cutoffMs, ordered by how many bookmarks
|
||||
// reference them (descending) then least-recently-checked first. One Site per
|
||||
@@ -1356,7 +1522,7 @@ func (s *Store) DueForLatestCheck(site string, cutoffMs, ceilingMs int64) ([]Ser
|
||||
AND (s.finished_at = 0 OR s.force_poll_at > s.latest_checked_at)
|
||||
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover,
|
||||
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
|
||||
s.force_poll_at, s.finished_at
|
||||
s.site_completed_at, s.force_poll_at, s.finished_at
|
||||
HAVING (COUNT(*) > 1
|
||||
OR s.latest_sighted_at <= $2::bigint
|
||||
OR s.latest_checked_at <= $3::bigint
|
||||
@@ -1491,6 +1657,22 @@ func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSiteCompletedAt stores when the last successful read saw the Site's
|
||||
// completed value, zero meaning it did not. Series-level, like the Latest
|
||||
// Chapter: it is a fact about the work, not about one Reader's bookmark, and
|
||||
// the bookmark's updated_at is never touched — this is not reading progress
|
||||
// and must not reorder any Reader's list, the same rule SetLatestChapter's
|
||||
// comment states. Touching a missing series is not an error: the row may
|
||||
// have been orphaned, and the caller's read decides what exists.
|
||||
func (s *Store) SetSiteCompletedAt(site, seriesID string, at int64) error {
|
||||
if _, err := s.db.Exec(
|
||||
`UPDATE series SET site_completed_at = $1 WHERE site = $2 AND series_id = $3`,
|
||||
at, site, seriesID); err != nil {
|
||||
return fmt.Errorf("set site completed %s:%s: %w", site, seriesID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSeriesURL stores the owner's repair for a Series' source address
|
||||
// (issue #151): the one write that lifts the write-once rule documented on
|
||||
// Series.SeriesURL. It is a store, not a verification — the caller has
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -2611,3 +2612,288 @@ func TestRemoveSeriesMissingKeyIsCleanNoOp(t *testing.T) {
|
||||
t.Fatalf("RemoveSeries on a missing key = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
// The failure row's existence is the failure state (ADR-0016):
|
||||
// RecordSeriesFailure upserts the outcome word, keeps the original
|
||||
// failing_since through a word change, and an unchanged word writes nothing
|
||||
// at all — a broken Series costs the same as a healthy one every hour.
|
||||
func TestRecordSeriesFailureUpsertKeepsFailingSince(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
|
||||
if err := s.RecordSeriesFailure("asura", "solo", "not_found", 1000); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
word, since, found := failureRow(t, s, "asura", "solo")
|
||||
if !found || word != "not_found" || since != 1000 {
|
||||
t.Fatalf("row after create = (%q, %d, %v), want (not_found, 1000, true)", word, since, found)
|
||||
}
|
||||
|
||||
// A changed word updates the outcome and never touches failing_since.
|
||||
if err := s.RecordSeriesFailure("asura", "solo", "errors", 2000); err != nil {
|
||||
t.Fatalf("record changed word: %v", err)
|
||||
}
|
||||
word, since, found = failureRow(t, s, "asura", "solo")
|
||||
if !found || word != "errors" || since != 1000 {
|
||||
t.Fatalf("row after word change = (%q, %d, %v), want (errors, 1000, true)", word, since, found)
|
||||
}
|
||||
|
||||
// An identical word writes nothing: the stamp survives a later now.
|
||||
if err := s.RecordSeriesFailure("asura", "solo", "errors", 3000); err != nil {
|
||||
t.Fatalf("record identical word: %v", err)
|
||||
}
|
||||
word, since, found = failureRow(t, s, "asura", "solo")
|
||||
if !found || word != "errors" || since != 1000 {
|
||||
t.Fatalf("row after identical word = (%q, %d, %v), want the earlier (errors, 1000, true)", word, since, found)
|
||||
}
|
||||
}
|
||||
|
||||
// ClearSeriesFailure deletes the row, and a row that is not there is not an
|
||||
// error — the poller clears on every successful read, so most clears find
|
||||
// nothing.
|
||||
func TestClearSeriesFailure(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
|
||||
if err := s.ClearSeriesFailure("asura", "solo"); err != nil {
|
||||
t.Fatalf("clear a missing row: %v, want nil", err)
|
||||
}
|
||||
if err := s.RecordSeriesFailure("asura", "solo", "not_found", 1000); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
if err := s.ClearSeriesFailure("asura", "solo"); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
if _, _, found := failureRow(t, s, "asura", "solo"); found {
|
||||
t.Fatal("row after clear: still present")
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting a Series takes its failure row with it (the composite FK's
|
||||
// cascade), so orphan removal stays a single statement.
|
||||
func TestRemoveSeriesCascadesToFailureRow(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
||||
if err := s.RecordSeriesFailure("asura", "solo", "not_found", 1000); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
if err := s.Delete(s.OwnerID(), "asura:solo"); err != nil {
|
||||
t.Fatalf("delete bookmark: %v", err)
|
||||
}
|
||||
if err := s.RemoveSeries("asura", "solo"); err != nil {
|
||||
t.Fatalf("RemoveSeries: %v", err)
|
||||
}
|
||||
if _, _, found := failureRow(t, s, "asura", "solo"); found {
|
||||
t.Fatal("failure row after series delete: still present (no cascade)")
|
||||
}
|
||||
}
|
||||
|
||||
// failureRow reads one Series' failure row as stored.
|
||||
func failureRow(t *testing.T, s *Store, site, seriesID string) (word string, since int64, found bool) {
|
||||
t.Helper()
|
||||
err := s.db.QueryRow(
|
||||
`SELECT outcome, failing_since FROM poll_failures WHERE site = $1 AND series_id = $2`,
|
||||
site, seriesID).Scan(&word, &since)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", 0, false
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read failure row %s:%s: %v", site, seriesID, err)
|
||||
}
|
||||
return word, since, true
|
||||
}
|
||||
|
||||
// Owner notices are one row per episode: the row's presence is the whole
|
||||
// state. MarkNoticeSent stamps it, NoticeSent reads it, and ClearNotice
|
||||
// forgets it — including a row that was never there, which is not an error.
|
||||
func TestOwnerNoticeRows(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
if sent, err := s.NoticeSent("stall", "comix"); err != nil || sent {
|
||||
t.Fatalf("NoticeSent on a fresh table = %v, %v; want false, nil", sent, err)
|
||||
}
|
||||
|
||||
if err := s.MarkNoticeSent("stall", "comix", 4242); err != nil {
|
||||
t.Fatalf("MarkNoticeSent: %v", err)
|
||||
}
|
||||
if sent, err := s.NoticeSent("stall", "comix"); err != nil || !sent {
|
||||
t.Fatalf("NoticeSent after mark = %v, %v; want true, nil", sent, err)
|
||||
}
|
||||
|
||||
// Re-marking the same episode just moves the stamp: the row is a lock,
|
||||
// not a log. A different site is its own episode — the key is
|
||||
// (condition, site).
|
||||
if err := s.MarkNoticeSent("stall", "comix", 4343); err != nil {
|
||||
t.Fatalf("re-mark: %v", err)
|
||||
}
|
||||
if sent, err := s.NoticeSent("stall", "kagane"); err != nil || sent {
|
||||
t.Fatalf("NoticeSent on another site = %v, %v; want false, nil", sent, err)
|
||||
}
|
||||
|
||||
if err := s.ClearNotice("stall", "comix"); err != nil {
|
||||
t.Fatalf("ClearNotice: %v", err)
|
||||
}
|
||||
if sent, err := s.NoticeSent("stall", "comix"); err != nil || sent {
|
||||
t.Fatalf("NoticeSent after clear = %v, %v; want false, nil", sent, err)
|
||||
}
|
||||
if err := s.ClearNotice("stall", "comix"); err != nil {
|
||||
t.Fatalf("ClearNotice on a missing row: %v", err)
|
||||
}
|
||||
}
|
||||
// RefusingSince reads one Site's current unbroken run of refusing passes
|
||||
// (issue #172). A refusing pass is one the Lane gate returned early from
|
||||
// (skip 'refusing') or one whose loop counted refusals (refused > 0) — the
|
||||
// two shapes a persistently-challenged Site alternates between, so the run
|
||||
// must not break when the gate row follows the refusal row. A Site whose
|
||||
// latest pass did not refuse is absent, nothing after the caller's clock
|
||||
// counts, and a run older than the log answers with the oldest row present.
|
||||
func TestRefusingSince(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seed := func(site string, ranAt int64, skip string, refused int) {
|
||||
t.Helper()
|
||||
if err := s.RecordLanePass(LanePass{Site: site, RanAt: ranAt, Skip: skip, Refused: refused}, -1); err != nil {
|
||||
t.Fatalf("RecordLanePass(%s/%d): %v", site, ranAt, err)
|
||||
}
|
||||
}
|
||||
// asura: a refusing run broken by a healthy pass, then resumed; the
|
||||
// current run began at the pass after the break.
|
||||
seed("asura", 100, "", 2)
|
||||
seed("asura", 200, "", 0)
|
||||
seed("asura", 300, "refusing", 0)
|
||||
seed("asura", 400, "", 2)
|
||||
// demonic: the latest pass is healthy — no run, absent.
|
||||
seed("demonic", 100, "", 2)
|
||||
seed("demonic", 200, "", 0)
|
||||
// novelfull: a healthy pass after now must not break the run — the run
|
||||
// is judged as of the caller's clock.
|
||||
seed("novelfull", 100, "", 2)
|
||||
seed("novelfull", 600, "", 0)
|
||||
// comix: an unbroken run reaches back to the oldest row present.
|
||||
seed("comix", 100, "", 2)
|
||||
seed("comix", 200, "refusing", 0)
|
||||
|
||||
got, err := s.RefusingSince(450)
|
||||
if err != nil {
|
||||
t.Fatalf("RefusingSince: %v", err)
|
||||
}
|
||||
want := map[string]int64{"asura": 300, "novelfull": 100, "comix": 100}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("RefusingSince = %v, want %v", got, want)
|
||||
}
|
||||
for site, since := range want {
|
||||
if got[site] != since {
|
||||
t.Fatalf("RefusingSince[%s] = %d, want %d (got %v)", site, got[site], since, got)
|
||||
}
|
||||
}
|
||||
if _, ok := got["demonic"]; ok {
|
||||
t.Fatalf("RefusingSince names demonic, whose latest pass did not refuse: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// SidecarOK reads, per Site, the most recent pass that actually reached the
|
||||
// sidecar (issue #172): the pass ran its loop (skip '') and no read lost
|
||||
// Chrome (unreachable 0). A challenge answer still reached the sidecar, a
|
||||
// lost sidecar and every skip value did not, and a Site with no qualifying
|
||||
// pass — an asleep Lane included — is absent.
|
||||
func TestSidecarOK(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
seed := func(site string, ranAt int64, skip string, unreachable int) {
|
||||
t.Helper()
|
||||
if err := s.RecordLanePass(LanePass{Site: site, RanAt: ranAt, Skip: skip, Unreachable: unreachable}, -1); err != nil {
|
||||
t.Fatalf("RecordLanePass(%s/%d): %v", site, ranAt, err)
|
||||
}
|
||||
}
|
||||
// comix: only the skip='' unreachable=0 pass reached the sidecar; the
|
||||
// mid-loop browser-loss row (skip '', unreachable > 0) and the skip
|
||||
// values never did.
|
||||
seed("comix", 100, "", 1)
|
||||
seed("comix", 200, "sidecar-down", 0)
|
||||
seed("comix", 300, "", 0)
|
||||
seed("comix", 400, "refusing", 0)
|
||||
// kagane: two passes reached the sidecar; the newest wins.
|
||||
seed("kagane", 150, "", 0)
|
||||
seed("kagane", 250, "", 0)
|
||||
// novelfull: an asleep Lane never reached it.
|
||||
seed("novelfull", 120, "asleep", 0)
|
||||
|
||||
got, err := s.SidecarOK([]string{"comix", "kagane", "novelfull", "lightnovelworld"})
|
||||
if err != nil {
|
||||
t.Fatalf("SidecarOK: %v", err)
|
||||
}
|
||||
want := map[string]int64{"comix": 300, "kagane": 250}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("SidecarOK = %v, want %v", got, want)
|
||||
}
|
||||
for site, ranAt := range want {
|
||||
if got[site] != ranAt {
|
||||
t.Fatalf("SidecarOK[%s] = %d, want %d (got %v)", site, got[site], ranAt, got)
|
||||
}
|
||||
}
|
||||
for _, site := range []string{"novelfull", "lightnovelworld"} {
|
||||
if _, ok := got[site]; ok {
|
||||
t.Fatalf("SidecarOK names %s, which never reached the sidecar: %v", site, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NoChapterShare reads, per Site, the share of its Series holding a
|
||||
// no-chapter failure row older than the cutoff (issue #172): old rows over
|
||||
// the Site's whole Series count, so a four-Series Site and a 200-Series
|
||||
// Site are judged on the same scale. A fresh no-chapter row and a row of
|
||||
// any other outcome do not count.
|
||||
func TestNoChapterShare(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
for i := 1; i <= 4; i++ {
|
||||
seedForCheck(t, s, fmt.Sprintf("asura:a%d", i), fmt.Sprintf("https://asurascans.com/comics/a%d", i), 0)
|
||||
}
|
||||
seedForCheck(t, s, "comix:c1", "https://comix.to/title/c1", 0)
|
||||
seedForCheck(t, s, "comix:c2", "https://comix.to/title/c2", 0)
|
||||
seedForCheck(t, s, "demonic:d1", "https://demonicscans.org/series/d1", 0)
|
||||
for i := 1; i <= 200; i++ {
|
||||
seedForCheck(t, s, fmt.Sprintf("novelfull:n%d", i), fmt.Sprintf("https://novelfull.com/n%d.html", i), 0)
|
||||
}
|
||||
|
||||
const cutoff = 1000
|
||||
oldNoChapter := func(site, seriesID string) {
|
||||
t.Helper()
|
||||
if err := s.RecordSeriesFailure(site, seriesID, "no_chapter", 100); err != nil {
|
||||
t.Fatalf("seed no-chapter failure %s:%s: %v", site, seriesID, err)
|
||||
}
|
||||
}
|
||||
// asura: three of four hold an old no-chapter row; the fourth's
|
||||
// no-chapter row is fresh — it is not old, so it does not count.
|
||||
oldNoChapter("asura", "a1")
|
||||
oldNoChapter("asura", "a2")
|
||||
oldNoChapter("asura", "a3")
|
||||
if err := s.RecordSeriesFailure("asura", "a4", "no_chapter", 2000); err != nil {
|
||||
t.Fatalf("seed fresh no-chapter failure: %v", err)
|
||||
}
|
||||
// comix: an old no-chapter row and an old errors row — the errors row
|
||||
// is not a no-chapter row, so the share is 1/2, the exact boundary.
|
||||
oldNoChapter("comix", "c1")
|
||||
if err := s.RecordSeriesFailure("comix", "c2", "errors", 100); err != nil {
|
||||
t.Fatalf("seed errors failure: %v", err)
|
||||
}
|
||||
// demonic and novelfull: one old no-chapter row each, against sites of
|
||||
// one and two hundred Series.
|
||||
oldNoChapter("demonic", "d1")
|
||||
oldNoChapter("novelfull", "n1")
|
||||
|
||||
got, err := s.NoChapterShare(cutoff)
|
||||
if err != nil {
|
||||
t.Fatalf("NoChapterShare: %v", err)
|
||||
}
|
||||
checks := []struct {
|
||||
site string
|
||||
want float64
|
||||
}{
|
||||
{"asura", 3.0 / 4.0},
|
||||
{"comix", 1.0 / 2.0},
|
||||
{"demonic", 1.0 / 1.0},
|
||||
{"novelfull", 1.0 / 200.0},
|
||||
}
|
||||
for _, c := range checks {
|
||||
if got[c.site] != c.want {
|
||||
t.Fatalf("NoChapterShare[%s] = %v, want %v", c.site, got[c.site], c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,16 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/latest"
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
// ownerWindow is the staleness boundary the Series list's "not checked in
|
||||
// 12h" filter compares against. Declared once; later admin tickets read it.
|
||||
const ownerWindow = 12 * time.Hour
|
||||
// 12h" filter compares against. It reads latest.OwnerWindow — the one place
|
||||
// the class-level twelve hours lives, shared with the owner-notice
|
||||
// conditions (issue #171).
|
||||
const ownerWindow = latest.OwnerWindow
|
||||
|
||||
// adminView is the shared shell data for an administrative page and the roster
|
||||
// fragment returned after a Reader action.
|
||||
|
||||
@@ -56,6 +56,10 @@ type laneRow struct {
|
||||
// offer Resume from the moment the owner presses Pause, with no pass
|
||||
// having run to record it (issue #147).
|
||||
Paused bool
|
||||
// FailingHref is the one navigation the row offers: the Site's name
|
||||
// links to that Site's failing Series. The chips beside it stay
|
||||
// unlinked; the withdrawn promise lives on outcomeChips (issue #167).
|
||||
FailingHref string
|
||||
}
|
||||
|
||||
// chip is one named outcome count over the owner's window.
|
||||
@@ -223,6 +227,7 @@ func buildLaneRow(p store.LanePass, o store.SiteOutcomes, now time.Time) laneRow
|
||||
if p.GapMS > 0 {
|
||||
row.Gap = (time.Duration(p.GapMS) * time.Millisecond).Truncate(time.Second).String()
|
||||
}
|
||||
row.FailingHref = seriesListHref(store.SeriesFilterFailing, p.Site, "", 0)
|
||||
row.Chips = outcomeChips(o)
|
||||
row.HasChips = len(row.Chips) > 0
|
||||
row.StatePhrase, row.StateGood, row.Attention = laneState(p, now)
|
||||
@@ -232,17 +237,24 @@ func buildLaneRow(p store.LanePass, o store.SiteOutcomes, now time.Time) laneRow
|
||||
|
||||
// outcomeChips lists a Site's nonzero window sums in the taxonomy's fixed
|
||||
// order, so the chips never reorder as the window changes. None observed is
|
||||
// written by the template, not drawn as a confident zero count.
|
||||
// written by the template, not drawn as a confident zero count. The names are
|
||||
// spelled through outcomeWord, the one vocabulary the failure surface shares
|
||||
// with the Series list's fact line (issue #167). The counts stay unlinked
|
||||
// permanently — a withdrawn promise: two of the six words write no per-Series
|
||||
// state, and the other four count attempts inside the owner window while the
|
||||
// failing filter lists Series failing now, so neither set contains the other
|
||||
// and no chip can be a door to its list.
|
||||
func outcomeChips(o store.SiteOutcomes) []chip {
|
||||
fixed := []struct {
|
||||
name string
|
||||
count int
|
||||
}{
|
||||
{"refused", o.Refused},
|
||||
{"unreachable", o.Unreachable},
|
||||
{"no chapter", o.NoChapter},
|
||||
{"unfetchable", o.Unfetchable},
|
||||
{"errors", o.Errors},
|
||||
{outcomeWord("refused"), o.Refused},
|
||||
{outcomeWord("unreachable"), o.Unreachable},
|
||||
{outcomeWord("no_chapter"), o.NoChapter},
|
||||
{outcomeWord("unfetchable"), o.Unfetchable},
|
||||
{outcomeWord("not_found"), o.NotFound},
|
||||
{outcomeWord("errors"), o.Errors},
|
||||
}
|
||||
var out []chip
|
||||
for _, f := range fixed {
|
||||
|
||||
@@ -2,8 +2,10 @@ package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/latest"
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
@@ -23,10 +25,10 @@ type overviewView struct {
|
||||
// as stale + never_checked: a never-checked Series is already counted on
|
||||
// its own filter, and the verdict wants the inclusive number.
|
||||
Unchecked int
|
||||
// Hygiene is the seven problem filters in the Series list's own render
|
||||
// order plus the finished figure riding last (informational); Library is
|
||||
// the library split plus the roster. Every figure is a door into the list
|
||||
// that counts it, except a zero.
|
||||
// Hygiene is the problem filters in the Series list's own render order
|
||||
// plus the informational tail — finished, then site completed — riding
|
||||
// last; Library is the library split plus the roster. Every figure is a
|
||||
// door into the list that counts it, except a zero.
|
||||
Hygiene []fig
|
||||
Library []fig
|
||||
// Sites is the per-Site library shape table, one row per Site with any
|
||||
@@ -60,10 +62,13 @@ type siteRow struct {
|
||||
}
|
||||
|
||||
// overviewView assembles the landing page from the store's read model: one
|
||||
// SeriesShapes pass per filter summed in Go (the shipped surface offers nine
|
||||
// SeriesShapes pass per filter summed in Go (the shipped surface offers ten
|
||||
// grouped passes, not a stats query — #140), the pass log's latest pass per
|
||||
// Site, and the roster. A failure in any read is a 500 with a logged reason,
|
||||
// never a page of silent zeroes.
|
||||
// Site, and the roster. A failure in the SeriesShapes, pass or roster reads
|
||||
// is a 500 with a logged reason, never a page of silent zeroes. The three
|
||||
// fault-input reads (RefusingSince, SidecarOK, NoChapterShare) fail open:
|
||||
// a failing read logs and contributes no fault, so the landing page still
|
||||
// renders — the same fail-open the poller uses for owner notices.
|
||||
func (h *Handler) overviewView() (overviewView, error) {
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-ownerWindow).UnixMilli()
|
||||
@@ -92,12 +97,36 @@ func (h *Handler) overviewView() (overviewView, error) {
|
||||
|
||||
view := overviewView{Waiting: waiting(passes)}
|
||||
view.Unchecked = totals[store.SeriesFilterStale] + totals[store.SeriesFilterNeverChecked]
|
||||
view.Verdict, view.HasCounts = overviewVerdict(passes, now)
|
||||
var refusingSince map[string]int64
|
||||
if m, err := h.store.RefusingSince(now.UnixMilli()); err != nil {
|
||||
log.Printf("admin overview: refusing since: %v", err)
|
||||
} else {
|
||||
refusingSince = m
|
||||
}
|
||||
var sidecarOK map[string]int64
|
||||
if m, err := h.store.SidecarOK(latest.BrowserBackedSites()); err != nil {
|
||||
log.Printf("admin overview: sidecar ok: %v", err)
|
||||
} else {
|
||||
sidecarOK = m
|
||||
}
|
||||
var noChapterShare map[string]float64
|
||||
if m, err := h.store.NoChapterShare(cutoff); err != nil {
|
||||
log.Printf("admin overview: no-chapter share: %v", err)
|
||||
} else {
|
||||
noChapterShare = m
|
||||
}
|
||||
faults := latest.FaultsFrom(latest.FaultInput{
|
||||
Passes: passes,
|
||||
RefusingSince: refusingSince,
|
||||
SidecarOK: sidecarOK,
|
||||
NoChapterShare: noChapterShare,
|
||||
}, now)
|
||||
view.Verdict, view.HasCounts = overviewVerdict(passes, faults)
|
||||
|
||||
// The hygiene figures, in seriesFilterOrder's tail: the seven problem
|
||||
// filters in permanent-then-fixable order, then the finished figure last —
|
||||
// informational, not a problem, and last because seriesFilterOrder appends
|
||||
// it there. The All filter's count belongs to the Library block, not to a
|
||||
// The hygiene figures, in seriesFilterOrder's tail: the problem filters
|
||||
// in permanent-then-fixable order, then the informational tail — finished
|
||||
// and site completed — riding last because seriesFilterOrder appends them
|
||||
// there. The All filter's count belongs to the Library block, not to a
|
||||
// "hygiene" figure.
|
||||
hygiene := make([]fig, 0, len(seriesFilterOrder)-1)
|
||||
for _, name := range seriesFilterOrder[1:] {
|
||||
@@ -137,11 +166,14 @@ func (h *Handler) overviewView() (overviewView, error) {
|
||||
row := siteRow{
|
||||
Site: sh.Site,
|
||||
SiteHref: seriesListHref("", sh.Site, "", 0),
|
||||
// The labels are the thead's words, carried per cell because the
|
||||
// phone layout drops the thead: four bare counts in a row are
|
||||
// unreadable without them (they render as the cell's prefix).
|
||||
Figs: []fig{
|
||||
door("", sh.Total, seriesListHref("", sh.Site, "", 0)),
|
||||
door("", siteCounts[store.SeriesFilterNoCover][sh.Site], seriesListHref(store.SeriesFilterNoCover, sh.Site, "", 0)),
|
||||
door("", siteCounts[store.SeriesFilterNeverChecked][sh.Site], seriesListHref(store.SeriesFilterNeverChecked, sh.Site, "", 0)),
|
||||
door("", siteCounts[store.SeriesFilterStale][sh.Site], seriesListHref(store.SeriesFilterStale, sh.Site, "", 0)),
|
||||
door("series", sh.Total, seriesListHref("", sh.Site, "", 0)),
|
||||
door("no cover", siteCounts[store.SeriesFilterNoCover][sh.Site], seriesListHref(store.SeriesFilterNoCover, sh.Site, "", 0)),
|
||||
door("never chk", siteCounts[store.SeriesFilterNeverChecked][sh.Site], seriesListHref(store.SeriesFilterNeverChecked, sh.Site, "", 0)),
|
||||
door("stale", siteCounts[store.SeriesFilterStale][sh.Site], seriesListHref(store.SeriesFilterStale, sh.Site, "", 0)),
|
||||
},
|
||||
}
|
||||
if p, ok := passBySite[sh.Site]; ok {
|
||||
@@ -165,28 +197,26 @@ func door(label string, count int, href string) fig {
|
||||
return fig{Label: label, Href: href, Count: count}
|
||||
}
|
||||
|
||||
// overviewVerdict decides the landing page's one line from the latest pass
|
||||
// per Site: no passes at all is "no Lane has reported yet" — never confident
|
||||
// zeroes; otherwise the count of Lanes whose last pass needs the owner, or
|
||||
// "all lanes healthy". The count comes from the same laneState judgement the
|
||||
// Lanes page colours on, so the two pages cannot disagree on what a fault is.
|
||||
func overviewVerdict(passes []store.LanePass, now time.Time) (phrase string, counts bool) {
|
||||
// overviewVerdict decides the landing page's one line: no passes at all is
|
||||
// "no Lane has reported yet" — never confident zeroes; otherwise the count of
|
||||
// faults from the shared FaultsFrom judgement, so the page and the push
|
||||
// cannot disagree. Zero faults is "all lanes healthy". A sidecar-down fault
|
||||
// carries Site == "" and is still one fault. The Lanes page's per-row
|
||||
// laneState is a different question (is this Lane's last pass healthy) from
|
||||
// the notice class, and its known false positives live there deliberately, so
|
||||
// the two now differ.
|
||||
func overviewVerdict(passes []store.LanePass, faults []latest.Fault) (phrase string, counts bool) {
|
||||
if len(passes) == 0 {
|
||||
return "no Lane has reported yet", false
|
||||
}
|
||||
attention := 0
|
||||
for _, p := range passes {
|
||||
if _, _, attn := laneState(p, now); attn {
|
||||
attention++
|
||||
}
|
||||
}
|
||||
if attention == 0 {
|
||||
n := len(faults)
|
||||
if n == 0 {
|
||||
return "all lanes healthy", true
|
||||
}
|
||||
if attention == 1 {
|
||||
if n == 1 {
|
||||
return "1 lane needs a look", true
|
||||
}
|
||||
return fmt.Sprintf("%d lanes need a look", attention), true
|
||||
return fmt.Sprintf("%d lanes need a look", n), true
|
||||
}
|
||||
|
||||
// waiting sums Due over the latest pass per Site: how many Series the Lanes
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The three administrative tables are grids on a mouse and label-value pairs
|
||||
// on a phone, where the media query drops the .thead and prints each cell's
|
||||
// data-label as its prefix instead. Without the attribute the collapsed row
|
||||
// renders as bare figures — "1200.25 3d 4", "12 40 6h" — which no reader can
|
||||
// decode, and nothing in Go or in a desktop render says so. Every cell whose
|
||||
// only column heading was the .thead is asserted here.
|
||||
func TestAdminTablesLabelCollapsedCells(t *testing.T) {
|
||||
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFS: %v", err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
data any
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
"series-row",
|
||||
seriesRowView{Key: "asura:x", Title: "Chronicles", Site: "asura", Ch: "1200.25", Age: "3d", Readers: 4},
|
||||
[]string{`data-label="ch"`, `data-label="checked"`, `data-label="readers"`},
|
||||
},
|
||||
{
|
||||
"lanes",
|
||||
lanesView{Rows: []laneRow{{Site: "asura", Due: 12, Checked: 40, Gap: "6h", Ran: "4m ago"}}},
|
||||
[]string{`data-label="due"`, `data-label="checked"`, `data-label="gap"`},
|
||||
},
|
||||
{
|
||||
// The Site table's figures are a range over one slice, so their
|
||||
// labels are the ones overviewView puts on the figs.
|
||||
"overview",
|
||||
overviewView{Sites: []siteRow{{Site: "asura", Figs: []fig{
|
||||
{Label: "series", Href: "/admin/series", Count: 143},
|
||||
{Label: "stale", Count: 0},
|
||||
}}}},
|
||||
[]string{`data-label="series"`, `data-label="stale"`},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var out strings.Builder
|
||||
if err := tmpl.ExecuteTemplate(&out, tc.name, tc.data); err != nil {
|
||||
t.Fatalf("ExecuteTemplate: %v", err)
|
||||
}
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Errorf("missing %s: the collapsed phone row renders this cell as a bare figure", want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -25,21 +25,25 @@ const seriesPageSize = 50
|
||||
// the labels are read by later admin tickets too, so the map and the
|
||||
// constants cannot drift apart.
|
||||
var seriesFilterLabels = map[string]string{
|
||||
store.SeriesFilterAll: "All series",
|
||||
store.SeriesFilterNoURL: "No series URL",
|
||||
store.SeriesFilterNoChapter: "Never read a chapter",
|
||||
store.SeriesFilterNoReaders: "No Readers",
|
||||
store.SeriesFilterNeverChecked: "Never checked",
|
||||
store.SeriesFilterStale: "Not checked in 12h",
|
||||
store.SeriesFilterNoCover: "No cover",
|
||||
store.SeriesFilterReaderReport: "Latest from a Reader",
|
||||
store.SeriesFilterFinished: "Finished",
|
||||
store.SeriesFilterAll: "All series",
|
||||
store.SeriesFilterNoURL: "No series URL",
|
||||
store.SeriesFilterNoChapter: "Never read a chapter",
|
||||
store.SeriesFilterNoReaders: "No Readers",
|
||||
store.SeriesFilterNeverChecked: "Never checked",
|
||||
store.SeriesFilterStale: "Not checked in 12h",
|
||||
store.SeriesFilterNoCover: "No cover",
|
||||
store.SeriesFilterReaderReport: "Latest from a Reader",
|
||||
store.SeriesFilterFailing: "Failing over 12h",
|
||||
store.SeriesFilterUnverified: "Unverified Reader number",
|
||||
store.SeriesFilterFinished: "Finished",
|
||||
store.SeriesFilterSiteCompleted: "Site says completed",
|
||||
}
|
||||
|
||||
// seriesFilterOrder is the select's render order: All first, then the
|
||||
// permanent repairs, then the fixable ones (issue #140). Finished rides the
|
||||
// tail, last — deliberate, not a repair — and the Overview's stats block
|
||||
// renders the same tail, which is what sits the finished figure last there.
|
||||
// permanent repairs, then the fixable ones (issue #140). Finished and the
|
||||
// site-completed hint ride the tail — deliberate, not repairs — and the
|
||||
// Overview's stats block renders the same tail, which is what sits the
|
||||
// finished and site-completed figures last there.
|
||||
var seriesFilterOrder = []string{
|
||||
store.SeriesFilterAll,
|
||||
store.SeriesFilterNoURL,
|
||||
@@ -49,7 +53,10 @@ var seriesFilterOrder = []string{
|
||||
store.SeriesFilterStale,
|
||||
store.SeriesFilterNoCover,
|
||||
store.SeriesFilterReaderReport,
|
||||
store.SeriesFilterFailing,
|
||||
store.SeriesFilterUnverified,
|
||||
store.SeriesFilterFinished,
|
||||
store.SeriesFilterSiteCompleted,
|
||||
}
|
||||
|
||||
// seriesListView is the Series list page's data. The template renders strings
|
||||
@@ -119,6 +126,11 @@ type seriesRowView struct {
|
||||
// page, where a press that retires a Series from the Lane is on purpose
|
||||
// and confirm-gated (issue #158).
|
||||
Finished bool
|
||||
// Failure names the standing failure and how long it has stood — "not
|
||||
// found · 3d ago", "" while no failure row stands. Its own field, never a
|
||||
// Notes chip: the chips cap at two plus a tail, so the one fact that
|
||||
// names the failure would be the most likely to be truncated away.
|
||||
Failure string
|
||||
}
|
||||
|
||||
// adminSeries renders the filterable, bookmarkable Series list: filter, Site,
|
||||
@@ -301,8 +313,8 @@ func (h *Handler) adminSeriesCorrectLatest(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
num, err := strconv.ParseFloat(r.PostFormValue("chapter"), 64)
|
||||
if err != nil || math.IsNaN(num) || math.IsInf(num, 0) || num <= 0 {
|
||||
http.Error(w, "chapter must be a finite number greater than zero", http.StatusBadRequest)
|
||||
if err != nil || math.IsNaN(num) || math.IsInf(num, 0) || num <= 0 || num > maxChapterNum {
|
||||
http.Error(w, "chapter must be a finite number between 0 and 10000", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
|
||||
@@ -493,10 +505,13 @@ func (h *Handler) adminSeriesRemove(w http.ResponseWriter, r *http.Request) {
|
||||
band = 1
|
||||
}
|
||||
h.render(w, http.StatusOK, "series-row", seriesRow(a, band, time.Now()))
|
||||
// The heading is an out-of-band append to a response whose status line has
|
||||
// already gone out with the row, so it is executed straight onto w —
|
||||
// h.render would send a second WriteHeader.
|
||||
if head, err := h.seriesListHeadView(r); err != nil {
|
||||
log.Printf("series remove %s: %v", site+":"+seriesID, err)
|
||||
} else {
|
||||
h.render(w, http.StatusOK, "series-list-head", head)
|
||||
} else if err := h.tmpl.ExecuteTemplate(w, "series-list-head", head); err != nil {
|
||||
log.Printf("series remove %s: render series-list-head oob: %v", site+":"+seriesID, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -679,6 +694,9 @@ func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView {
|
||||
row.Ch = "—"
|
||||
}
|
||||
row.Age = checkedAge(now, a.LatestCheckedAt)
|
||||
if a.FailureOutcome != "" {
|
||||
row.Failure = outcomeWord(a.FailureOutcome) + " · " + checkedAge(now, a.FailingSince)
|
||||
}
|
||||
notes := seriesNotes(a, now)
|
||||
if n := len(notes); n > 2 {
|
||||
row.Notes, row.More = notes[:2], n-2
|
||||
@@ -689,6 +707,20 @@ func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView {
|
||||
return row
|
||||
}
|
||||
|
||||
// outcomeWord spells the wire failure word as the Lanes chips spell it — a
|
||||
// space, not the underscore: not_found reads "not found", no_chapter "no
|
||||
// chapter". The remaining words are their own spelling, so an unknown word
|
||||
// degrades to itself rather than vanishing from the page.
|
||||
func outcomeWord(wire string) string {
|
||||
switch wire {
|
||||
case "not_found":
|
||||
return "not found"
|
||||
case "no_chapter":
|
||||
return "no chapter"
|
||||
}
|
||||
return wire
|
||||
}
|
||||
|
||||
// seriesNotes are a row's hygiene chips in the design's order: no URL, no
|
||||
// cover, orphan, stale, reader sighting.
|
||||
func seriesNotes(a store.AdminSeries, now time.Time) []string {
|
||||
|
||||
@@ -64,6 +64,15 @@ type seriesDetailView struct {
|
||||
// stands. It rides the meta fragment both presses swap, so the answer
|
||||
// itself shows how long the Series has been finished.
|
||||
FinishedSince string
|
||||
// Unverified is the sentence beside the Latest Chapter correction
|
||||
// control while a Reader's number stands behind a failure past the
|
||||
// owner window: the value is unconfirmed and the owner should not trust
|
||||
// it. It never names the Reader. "" otherwise.
|
||||
Unverified string
|
||||
// SiteCompleted is the hint's line, "" while the Site has said nothing or
|
||||
// the owner has finished the Series: "the site says this work is
|
||||
// completed (since 3d ago)". A hint, never a control (issue #170).
|
||||
SiteCompleted string
|
||||
}
|
||||
|
||||
// adminSeriesDetail renders one Series' page, keyed by the composite
|
||||
@@ -151,6 +160,17 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
|
||||
v.Corrected = correctedAge(time.Now(), a.LatestCorrectedAt)
|
||||
v.Finished = a.FinishedAt != 0
|
||||
v.FinishedSince = finishedAge(time.Now(), a.FinishedAt)
|
||||
// Unverified: a Reader's number standing behind a failure that has outlived
|
||||
// the owner window is a number nobody has re-checked since — say so next to
|
||||
// the correction control, without naming the Reader. A machine read or a
|
||||
// failure still inside the window carries no sentence; the failure itself
|
||||
// has not yet outlived the twelve hours' worth of trust.
|
||||
if a.RaisedByReader && a.FailureOutcome != "" && a.FailingSince < time.Now().Add(-ownerWindow).UnixMilli() {
|
||||
v.Unverified = "Unverified Reader number: the page has been failing for over 12h, so this Reader-reported chapter is unconfirmed."
|
||||
}
|
||||
if a.SiteCompletedAt != 0 && a.FinishedAt == 0 {
|
||||
v.SiteCompleted = "the site says this work is completed (since " + checkedAge(time.Now(), a.SiteCompletedAt) + ")"
|
||||
}
|
||||
|
||||
// Provenance: the actor class behind the current number, evaluated in the
|
||||
// order the classes outrank one another — the owner's stamp, which a
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
// The card carries two guarantees a browser can break that Go cannot see, so
|
||||
// they are asserted on the rendered markup:
|
||||
//
|
||||
// - the monogram is unconditional. A cover that 404s or a request the phone
|
||||
// drops leaves an <img> with no bytes, and the letter underneath it is the
|
||||
// only thing between that and the browser's broken-image glyph in a 93px
|
||||
// slot. Rendering it only when Cover is empty covers the wrong failure.
|
||||
// - the chapter field does not refuse its own value. It is pre-filled from
|
||||
// LastChapterNum, which the API accepts as any float, so a step that
|
||||
// quantises the field makes a series read to 1200.25 unsavable without
|
||||
// first editing a number the reader did not want to change.
|
||||
func TestCardRenderKeepsCoverFallbackAndAcceptsFractionalChapter(t *testing.T) {
|
||||
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFS: %v", err)
|
||||
}
|
||||
b := store.Bookmark{
|
||||
Key: "asura:x", Site: "asura", Title: "Chronicles", Status: store.StatusReading,
|
||||
Cover: "https://bookmarks.test/covers/abc", LastChapter: "Chapter 1200.25",
|
||||
LastChapterNum: 1200.25,
|
||||
}
|
||||
var out strings.Builder
|
||||
if err := tmpl.ExecuteTemplate(&out, "card", b); err != nil {
|
||||
t.Fatalf("ExecuteTemplate: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
|
||||
if !strings.Contains(got, `class="monogram"`) {
|
||||
t.Error("a card with a cover rendered no monogram: a failed image has no fallback")
|
||||
}
|
||||
if !strings.Contains(got, `step="any"`) {
|
||||
t.Error(`chapter input is not step="any": a fractional pre-filled value is unsavable`)
|
||||
}
|
||||
if !strings.Contains(got, `value="1200.25"`) {
|
||||
t.Errorf("chapter input is not pre-filled with the stored progress:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Every failing request must land somewhere visible. A card's own writes report
|
||||
// into its .error-inline; a tab switch and a credential rotation have no card,
|
||||
// and #notice is the only slot filter.js can fall back to — without it in the
|
||||
// shell they fail silently. The tab's own active-state move is guarded on the
|
||||
// response, or a failed switch underlines a bucket the list is not showing.
|
||||
func TestAppShellCarriesFailureNoticeAndGuardsTabState(t *testing.T) {
|
||||
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFS: %v", err)
|
||||
}
|
||||
var out strings.Builder
|
||||
view := listView{Lib: store.KindManga, Tab: "all"}
|
||||
if err := tmpl.ExecuteTemplate(&out, "app", view); err != nil {
|
||||
t.Fatalf("ExecuteTemplate: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
|
||||
if !strings.Contains(got, `id="notice"`) {
|
||||
t.Error("no #notice in the shell: a failure with no card to sit in reports nowhere")
|
||||
}
|
||||
if strings.Contains(got, `hx-on::after-request="setActiveTab(this)"`) {
|
||||
t.Error("a tab moves its active state unconditionally: a failed switch underlines the wrong bucket")
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,11 @@ type DiscordConfig struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
GuildID string
|
||||
// GuildName is a human-readable name for the guild that gates access.
|
||||
// It is never fetched from Discord; it is an optional display string the
|
||||
// operator sets (DISCORD_GUILD_NAME) so the login screen can name the
|
||||
// community without inventing one.
|
||||
GuildName string
|
||||
// RequiredRole, when non-empty, is a role ID a member must hold on top of
|
||||
// guild membership. Empty by default: membership alone suffices.
|
||||
RequiredRole string
|
||||
@@ -173,16 +178,21 @@ func (h *Handler) discordCallback(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
// The refusal is the same for a non-member and a member without the
|
||||
// required role, and it names neither the guild nor its id: an outsider
|
||||
// cannot tell whether the guild exists, let alone which one gates.
|
||||
// required role. When DISCORD_GUILD_NAME is set the message names the
|
||||
// community so a stranger knows which Discord to ask about; otherwise it
|
||||
// degrades to a generic community label and still gives the next step. It
|
||||
// never names the numeric guild id, which would not be actionable.
|
||||
//
|
||||
// It also returns before EnsureReader, so a refused sign-in leaves no
|
||||
// Reader row behind — the gate is the only thing standing between guild
|
||||
// membership and a library.
|
||||
if !isMember || (h.discord.RequiredRole != "" && !slices.Contains(member.Roles, h.discord.RequiredRole)) {
|
||||
h.limiter.Fail(ip, time.Now())
|
||||
h.renderLogin(w, http.StatusForbidden,
|
||||
"This Discord account is not a member of this community.")
|
||||
msg := "This Discord account is not a member of this community. Ask a member for an invite and try again."
|
||||
if h.discord.GuildName != "" {
|
||||
msg = fmt.Sprintf("This Discord account is not a member of the %s Discord. Ask a member for an invite and try again.", h.discord.GuildName)
|
||||
}
|
||||
h.renderLogin(w, http.StatusForbidden, msg)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,6 @@
|
||||
--measure-wide: 1080px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--measure-wide: 1080px;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-sheet {
|
||||
max-width: var(--measure-wide);
|
||||
}
|
||||
@@ -54,12 +48,18 @@
|
||||
|
||||
.navrow a:hover {
|
||||
color: var(--paper-dim);
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.navrow a.active {
|
||||
color: var(--paper);
|
||||
border-bottom: 2px solid var(--paper);
|
||||
color: var(--patina);
|
||||
border-bottom: 2px solid var(--patina);
|
||||
margin-bottom: -1px;
|
||||
background: var(--patina-wash);
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
margin-left: -6px;
|
||||
margin-right: -6px;
|
||||
}
|
||||
|
||||
.admin-page {
|
||||
@@ -97,15 +97,13 @@
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.admin-sheet .readerlist,
|
||||
.admin-sheet .lanelist {
|
||||
.admin-sheet .readerlist {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.admin-sheet .readerlist li,
|
||||
.admin-sheet .lanelist li {
|
||||
.admin-sheet .readerlist li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
@@ -115,12 +113,12 @@
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
|
||||
.admin-sheet .reader-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 18px;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
gap: 28px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-sheet .reader-actions .ghost,
|
||||
@@ -134,13 +132,11 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
|
||||
.admin-sheet .readerlist form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-sheet .lane-browser {
|
||||
padding: 12px 0 0;
|
||||
}
|
||||
|
||||
.admin-sheet .ghost.danger {
|
||||
color: var(--danger);
|
||||
@@ -159,9 +155,7 @@
|
||||
|
||||
.admin-sheet .reader-sessions,
|
||||
.admin-sheet .reader-sightings,
|
||||
.admin-sheet .reader-blocked,
|
||||
.admin-sheet .lane-fact,
|
||||
.admin-sheet .lane-mark {
|
||||
.admin-sheet .reader-blocked {
|
||||
font: 500 13px/1.4 var(--font-mono);
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
@@ -171,25 +165,14 @@
|
||||
color: var(--paper-dim);
|
||||
}
|
||||
|
||||
.admin-sheet .reader-sightings,
|
||||
.admin-sheet .lane-fact {
|
||||
.admin-sheet .reader-sightings {
|
||||
color: var(--mute);
|
||||
}
|
||||
|
||||
.admin-sheet .reader-blocked,
|
||||
.admin-sheet .lane-mark {
|
||||
.admin-sheet .reader-blocked {
|
||||
color: var(--patina);
|
||||
}
|
||||
|
||||
.admin-sheet .lane-site {
|
||||
font: 400 19px/1.2 var(--font-display);
|
||||
color: var(--paper-dim);
|
||||
}
|
||||
|
||||
.admin-sheet .lanelist li.attention .lane-site {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* A single grid keeps row rules continuous; cell padding supplies gutters. */
|
||||
.admin-sheet .sechead {
|
||||
display: flex;
|
||||
@@ -301,9 +284,10 @@
|
||||
}
|
||||
|
||||
.admin-sheet .tbl .c-act .ghost + .ghost {
|
||||
margin-left: 12px;
|
||||
margin-left: 28px;
|
||||
}
|
||||
|
||||
|
||||
.admin-sheet .tbl .trow > .confirm-row {
|
||||
grid-column: 1 / -1;
|
||||
padding: 10px 12px;
|
||||
@@ -324,6 +308,10 @@
|
||||
gap: 16px;
|
||||
padding: 11px 24px 11px 0;
|
||||
}
|
||||
.admin-sheet .stat + .stat {
|
||||
border-left: 1px solid var(--rule);
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.admin-sheet .stat .lbl {
|
||||
font: 500 13px/1.3 var(--font-mono);
|
||||
@@ -345,34 +333,53 @@
|
||||
.admin-sheet .stat a.fig {
|
||||
/* The hover rule below sets the border on :hover only, which shifts the
|
||||
row a pixel on hover; the transparent baseline holds the layout. */
|
||||
border-bottom: 1px solid transparent;
|
||||
border-bottom: 1px solid color-mix(in oklch, var(--patina) 22%, transparent);
|
||||
}
|
||||
|
||||
.admin-sheet .stat a.fig:hover {
|
||||
color: var(--patina);
|
||||
border-bottom: 1px solid var(--patina);
|
||||
background: var(--patina-wash);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.admin-sheet .stat + .stat {
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--rule);
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-sheet .mark,
|
||||
.admin-sheet .mark-faint {
|
||||
font: 500 13px/1 var(--font-mono);
|
||||
font: 500 12px/1.2 var(--font-mono);
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.admin-sheet .mark {
|
||||
color: var(--patina);
|
||||
background: var(--patina-wash);
|
||||
border-color: var(--patina-line);
|
||||
}
|
||||
|
||||
.admin-sheet .mark-faint {
|
||||
color: var(--mute-2);
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.admin-sheet .mark.mark-strong {
|
||||
font-size: 13px;
|
||||
letter-spacing: .14em;
|
||||
font-size: 12px;
|
||||
letter-spacing: .12em;
|
||||
color: var(--patina);
|
||||
background: var(--patina-wash);
|
||||
border-color: var(--patina-line);
|
||||
padding: 3px 7px;
|
||||
}
|
||||
|
||||
.admin-sheet .mark.mark-strong::before {
|
||||
@@ -387,9 +394,21 @@
|
||||
}
|
||||
|
||||
.admin-sheet .mark.bad {
|
||||
color: var(--danger);
|
||||
color: var(--slate);
|
||||
background: var(--slate-wash);
|
||||
border-color: var(--slate);
|
||||
font-weight: 600;
|
||||
}
|
||||
.admin-sheet .mark.bad::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: var(--slate);
|
||||
margin-right: 8px;
|
||||
vertical-align: .08em;
|
||||
border-radius: 1px; /* square, not circle — distinguishes from .mark-strong patina dot */
|
||||
}
|
||||
|
||||
.admin-sheet .tbl.sites {
|
||||
grid-template-columns: 170px repeat(4, 106px) minmax(180px, 1fr);
|
||||
}
|
||||
@@ -402,18 +421,56 @@
|
||||
}
|
||||
|
||||
.admin-sheet .tbl.sites .c-state {
|
||||
color: var(--patina);
|
||||
color: var(--mute-2);
|
||||
white-space: nowrap;
|
||||
padding: 2px 7px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
justify-self: start;
|
||||
align-self: center;
|
||||
font: 500 11px/1 var(--font-mono);
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-sheet .tbl.sites .c-state.bad,
|
||||
.admin-sheet .tbl.lanes .c-skip .bad,
|
||||
.admin-sheet .tbl.lanes .trow.attention .c-site {
|
||||
color: var(--danger);
|
||||
.admin-sheet .tbl.sites .c-state.ok {
|
||||
color: var(--patina);
|
||||
background: var(--patina-wash);
|
||||
border-color: var(--patina-line);
|
||||
}
|
||||
.admin-sheet .tbl.sites .c-state.bad {
|
||||
color: var(--slate);
|
||||
background: var(--slate-wash);
|
||||
border-color: var(--slate);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-sheet .tbl.lanes .c-skip .ok {
|
||||
color: var(--patina);
|
||||
background: var(--patina-wash);
|
||||
border: 1px solid var(--patina-line);
|
||||
padding: 1px 5px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.admin-sheet .tbl.lanes .trow.attention .c-site {
|
||||
color: var(--patina);
|
||||
}
|
||||
|
||||
.admin-sheet .tbl.lanes .c-skip .bad {
|
||||
color: var(--slate);
|
||||
background: var(--slate-wash);
|
||||
border: 1px solid var(--slate);
|
||||
padding: 1px 5px;
|
||||
border-radius: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-sheet .tbl.lanes .trow.attention {
|
||||
background: var(--patina-wash);
|
||||
box-shadow: inset 1px 0 0 var(--patina);
|
||||
}
|
||||
.admin-sheet .tbl.series .trow.attention {
|
||||
background: var(--patina-wash);
|
||||
box-shadow: inset 1px 0 0 var(--patina);
|
||||
}
|
||||
|
||||
.admin-sheet .tbl.sites .thead > *:nth-child(n+2):nth-child(-n+5),
|
||||
@@ -572,8 +629,13 @@
|
||||
.admin-sheet .segrow a.active {
|
||||
border-bottom-color: var(--patina);
|
||||
color: var(--patina);
|
||||
background: var(--patina-wash);
|
||||
}
|
||||
|
||||
.admin-sheet .segrow a:hover {
|
||||
color: var(--paper-dim);
|
||||
background: var(--hover);
|
||||
}
|
||||
.admin-sheet .listhead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -612,10 +674,11 @@
|
||||
}
|
||||
|
||||
.admin-sheet .pager .pg.disabled {
|
||||
color: var(--faint);
|
||||
color: var(--mute);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.admin-sheet .empty {
|
||||
padding: 28px 0;
|
||||
text-align: center;
|
||||
@@ -669,6 +732,27 @@
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.admin-sheet .readerlist li .confirm-row {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Busy: same muted hairline the reader library uses, never ember. */
|
||||
.admin-sheet .trow.htmx-request,
|
||||
.admin-sheet #readers.htmx-request,
|
||||
.admin-sheet #lanes.htmx-request,
|
||||
.admin-sheet #detail-meta.htmx-request,
|
||||
.admin-sheet .dform.htmx-request {
|
||||
opacity: .6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.admin-sheet .ghost:focus-visible {
|
||||
outline: 2px solid var(--patina);
|
||||
outline-offset: 2px;
|
||||
color: var(--paper);
|
||||
}
|
||||
|
||||
.admin-sheet .tbl.series .row-msg {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 6px;
|
||||
@@ -677,7 +761,6 @@
|
||||
}
|
||||
|
||||
.admin-sheet .detail-back {
|
||||
display: inline-block;
|
||||
margin: 18px 0 0;
|
||||
}
|
||||
|
||||
@@ -819,6 +902,23 @@
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* The thead is gone, so every cell that was only legible under a column
|
||||
heading prints its own. The label is data, not decoration: without it
|
||||
"12 40 6h" is three numbers in the same face with no way to tell which
|
||||
is which. Set in the mono face at label size so the figure still leads
|
||||
visually. */
|
||||
.admin-sheet .tbl.lanes [data-label] {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-sheet .tbl.lanes [data-label]::before {
|
||||
content: attr(data-label) " ";
|
||||
font-size: 11px;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute-2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 899px) {
|
||||
@@ -834,7 +934,12 @@
|
||||
.admin-sheet .tbl .trow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 10px;
|
||||
/* Cells no longer share a font size — a label-value pair is 11px over
|
||||
15px — so they hang off the baseline, not off the box top. */
|
||||
align-items: baseline;
|
||||
/* Wider than the mouse layout's column gutter: each cell now carries its
|
||||
own label, so the eye needs the pair to read as one unit. */
|
||||
gap: 2px 18px;
|
||||
padding: 11px 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
@@ -871,4 +976,69 @@
|
||||
.admin-sheet .tbl .c-act {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* A cell is one label-value pair and must not break between them. */
|
||||
.admin-sheet .tbl [data-label] {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-sheet .tbl [data-label]::before {
|
||||
content: attr(data-label) " ";
|
||||
font-size: 11px;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute-2);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- touch: the reader's library is sized for a thumb throughout, this
|
||||
sheet was not — it is a data surface that grew on a mouse. Gated on the
|
||||
input method rather than the width, because the phone is not the only
|
||||
coarse pointer and a 1400px touch laptop has the same problem. Vertical
|
||||
only: horizontal space is what this surface is short of. ---- */
|
||||
@media (pointer: coarse) {
|
||||
/* The figure is the door — the label beside it is not a link — so the row
|
||||
gives up its own padding to the cell that has to be hit. */
|
||||
.admin-sheet .stat {
|
||||
padding: 0 24px 0 0;
|
||||
}
|
||||
|
||||
/* A one-digit count is 11px wide: the minimum is on the box, not the
|
||||
glyph, or the door is a target the thumb has to aim at. */
|
||||
.admin-sheet .stat .fig {
|
||||
min-width: 44px;
|
||||
padding: 14px 0 14px 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.admin-sheet .tbl .c-title a,
|
||||
.admin-sheet .tbl a.fig,
|
||||
.admin-sheet .tbl a.c-site,
|
||||
/* The collapsed lanes row sets padding on every direct cell, which is a
|
||||
more specific selector than the line above — its own Site link has to be
|
||||
named here or it keeps the mouse metric. */
|
||||
.admin-sheet .tbl.lanes .trow > a.c-site {
|
||||
display: inline-block;
|
||||
min-width: 44px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.admin-sheet .pager {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.admin-sheet .pager .pg {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.admin-sheet .segrow a {
|
||||
padding: 15px 11px 16px;
|
||||
}
|
||||
|
||||
.admin-sheet .fsel select,
|
||||
.admin-sheet .pausebar select,
|
||||
.admin-sheet .dform input {
|
||||
padding-top: 11px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Title search runs entirely in the browser: the full list is already in the
|
||||
// DOM, so filtering it needs no request.
|
||||
(function () {
|
||||
function applyFilter() {
|
||||
function applyFilter(e) {
|
||||
var box = document.getElementById("search");
|
||||
if (!box) return;
|
||||
var query = box.value.trim();
|
||||
@@ -30,6 +30,21 @@
|
||||
none.hidden = !(needle !== "" && cards.length > 0 && visible === 0);
|
||||
if (!none.hidden) none.querySelector(".no-match-q").textContent = query;
|
||||
}
|
||||
|
||||
var isCardSwap = e && e.detail && e.detail.target && e.detail.target.classList && e.detail.target.classList.contains("card");
|
||||
if (isCardSwap) return;
|
||||
var announce = document.getElementById("sr-announce");
|
||||
if (announce) {
|
||||
if (needle === "") {
|
||||
announce.textContent = "";
|
||||
} else if (cards.length === 0) {
|
||||
announce.textContent = "";
|
||||
} else if (visible === 0) {
|
||||
announce.textContent = 'No titles match "' + query + '"';
|
||||
} else {
|
||||
announce.textContent = visible + ' ' + (visible === 1 ? 'title matches' : 'titles match') + ' "' + query + '"';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("input", function (e) {
|
||||
@@ -47,6 +62,54 @@
|
||||
// htmx replaces the list on a tab switch, so re-apply to the new cards.
|
||||
document.body.addEventListener("htmx:afterSwap", applyFilter);
|
||||
document.addEventListener("bmgr:refilter", applyFilter);
|
||||
// Focus handoff on remove: the card and its focused button vanish with the
|
||||
// swap, so focus would fall to <body> in silence. Remember the next card's
|
||||
// play link before the DELETE and hand focus to it after, or to the list
|
||||
// when it was the last row.
|
||||
var pendingRemove = null;
|
||||
document.body.addEventListener("htmx:beforeRequest", function (e) {
|
||||
var elt = e.detail.elt;
|
||||
if (!elt || !elt.getAttribute("hx-delete")) return;
|
||||
var card = elt.closest(".card");
|
||||
if (!card) return;
|
||||
var nxt = card.nextElementSibling;
|
||||
while (nxt && (!nxt.classList.contains("card") || nxt.hidden)) nxt = nxt.nextElementSibling;
|
||||
var play = nxt && nxt.querySelector(".play");
|
||||
pendingRemove = { card: card, play: play };
|
||||
});
|
||||
document.body.addEventListener("htmx:afterSwap", function (e) {
|
||||
if (!pendingRemove) return;
|
||||
if (e.detail.target !== pendingRemove.card) return;
|
||||
if (pendingRemove.play && document.body.contains(pendingRemove.play)) {
|
||||
pendingRemove.play.focus();
|
||||
} else {
|
||||
var list = document.getElementById("list");
|
||||
if (list) list.focus();
|
||||
}
|
||||
pendingRemove = null;
|
||||
var notice = document.getElementById("notice");
|
||||
if (notice && !notice.hidden) {
|
||||
notice.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
|
||||
});
|
||||
}
|
||||
});
|
||||
document.body.addEventListener("htmx:responseError", function () { pendingRemove = null; });
|
||||
document.body.addEventListener("htmx:sendError", function () { pendingRemove = null; });
|
||||
// htmx sets no aria-busy, so the in-flight window — up to the 15s timeout on
|
||||
// a phone in a dead zone — is silent to a screen reader; the busy bar and the
|
||||
// "Saving…" word are the visual half of the same signal. A successful swap
|
||||
// replaces the card and takes the attribute with it; afterRequest is for the
|
||||
// failures, where the card stays.
|
||||
document.body.addEventListener("htmx:beforeRequest", function (e) {
|
||||
var card = e.detail.elt && e.detail.elt.closest(".card");
|
||||
if (card) card.setAttribute("aria-busy", "true");
|
||||
});
|
||||
document.body.addEventListener("htmx:afterRequest", function (e) {
|
||||
var card = e.detail.elt && e.detail.elt.closest(".card");
|
||||
if (card) card.removeAttribute("aria-busy");
|
||||
});
|
||||
})();
|
||||
|
||||
function setActiveTab(el) {
|
||||
@@ -121,19 +184,72 @@ document.addEventListener("keydown", function (e) {
|
||||
if (e.key !== "Escape" || !e.target.closest) return;
|
||||
var card = e.target.closest(".card");
|
||||
var owner = card && card.querySelector(".actions .open");
|
||||
if (!owner) return;
|
||||
closeCardPanels(card.id.replace(/^card-/, ""));
|
||||
owner.focus();
|
||||
if (owner) {
|
||||
closeCardPanels(card.id.replace(/^card-/, ""));
|
||||
owner.focus();
|
||||
return;
|
||||
}
|
||||
// Admin confirm rows: any open .confirm-row that contains the focused element
|
||||
var row = e.target.closest(".confirm-row");
|
||||
if (row && !row.hidden) {
|
||||
row.hidden = true;
|
||||
var opener = row.id && document.querySelector('[aria-controls="' + row.id + '"]');
|
||||
if (opener) {
|
||||
opener.setAttribute("aria-expanded", "false");
|
||||
opener.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Also handle when focus is outside row but row is open on page (e.g., opener focused)
|
||||
var openRow = document.querySelector(".admin-sheet .confirm-row:not([hidden])");
|
||||
if (openRow) {
|
||||
openRow.hidden = true;
|
||||
var op = openRow.id && document.querySelector('[aria-controls="' + openRow.id + '"]');
|
||||
if (op) {
|
||||
op.setAttribute("aria-expanded", "false");
|
||||
op.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// A failed favourite/chapter/delete request leaves the card in place (htmx
|
||||
// does not swap on a non-2xx response) but otherwise gives no sign anything
|
||||
// went wrong. Surface it inline instead of leaving the tap looking ignored.
|
||||
(function () {
|
||||
function showError(elt, message, linkHref, linkText) {
|
||||
function slotFor(elt) {
|
||||
var card = elt.closest(".card");
|
||||
var slot = card && card.querySelector(".error-inline");
|
||||
if (!slot) return;
|
||||
if (card) {
|
||||
var s = card.querySelector(".error-inline");
|
||||
if (s) return s;
|
||||
}
|
||||
// Admin: per-form inline slot
|
||||
var dform = elt.closest(".dform");
|
||||
if (dform) {
|
||||
var ds = dform.querySelector(".error-inline");
|
||||
if (ds) return ds;
|
||||
}
|
||||
// Admin: per-reader roster
|
||||
var readers = elt.closest("#readers");
|
||||
if (readers) {
|
||||
var rs = readers.querySelector(".error-inline");
|
||||
if (rs) return rs;
|
||||
}
|
||||
// Admin: series row — reuse row-msg if present, otherwise fall back
|
||||
var trow = elt.closest(".trow");
|
||||
if (trow) {
|
||||
var ts = trow.querySelector(".error-inline") || trow.querySelector(".row-msg");
|
||||
if (ts) return ts;
|
||||
}
|
||||
return document.getElementById("notice");
|
||||
}
|
||||
|
||||
function inCard(slot) {
|
||||
return slot.classList.contains("error-inline");
|
||||
}
|
||||
|
||||
function show(slot, message, linkHref, linkText) {
|
||||
if (slot.id === "notice") slot.setAttribute("role", "status");
|
||||
slot.textContent = message;
|
||||
if (linkHref) {
|
||||
var a = document.createElement("a");
|
||||
@@ -155,9 +271,10 @@ document.addEventListener("keydown", function (e) {
|
||||
}
|
||||
|
||||
document.body.addEventListener("htmx:beforeRequest", function (e) {
|
||||
var card = e.detail.elt.closest(".card");
|
||||
var slot = card && card.querySelector(".error-inline");
|
||||
var slot = slotFor(e.detail.elt);
|
||||
if (slot) slot.hidden = true;
|
||||
var n = document.getElementById("notice");
|
||||
if (n && !n.hidden && n.textContent.indexOf("Removed ") === 0) n.hidden = true;
|
||||
});
|
||||
|
||||
// The handlers answer a bad value with http.Error, i.e. a short plain-text
|
||||
@@ -170,19 +287,31 @@ document.addEventListener("keydown", function (e) {
|
||||
}
|
||||
|
||||
document.body.addEventListener("htmx:responseError", function (e) {
|
||||
var slot = slotFor(e.detail.elt);
|
||||
if (!slot) return;
|
||||
var xhr = e.detail.xhr;
|
||||
// "Saved" is only true of a card's own write; a failed tab switch saved
|
||||
// nothing because it was never a write.
|
||||
if (xhr.status === 401) {
|
||||
showError(e.detail.elt, "Session expired — nothing was saved.", "/login", "Log in again");
|
||||
show(slot, inCard(slot) ? "Session expired — nothing was saved." : "Session expired.",
|
||||
"/login", "Log in again");
|
||||
return;
|
||||
}
|
||||
if (xhr.status === 400) {
|
||||
var reason = reasonFrom(xhr);
|
||||
showError(e.detail.elt, reason ? reason + "." : "That value wasn't accepted — check it and try again.");
|
||||
show(slot, reason ? reason + "." : "That value wasn't accepted — check it and try again.");
|
||||
return;
|
||||
}
|
||||
showError(e.detail.elt, "Couldn't save — try again.");
|
||||
show(slot, inCard(slot) ? "Couldn't save — try again." : "That didn't go through — try again.");
|
||||
});
|
||||
document.body.addEventListener("htmx:sendError", function (e) {
|
||||
showError(e.detail.elt, "No connection — try again.");
|
||||
var slot = slotFor(e.detail.elt);
|
||||
if (slot) show(slot, "No connection — try again.");
|
||||
});
|
||||
// htmx-config caps every request at 15s; without this the give-up is as
|
||||
// silent as the hang it replaced.
|
||||
document.body.addEventListener("htmx:timeout", function (e) {
|
||||
var slot = slotFor(e.detail.elt);
|
||||
if (slot) show(slot, "Timed out — try again.");
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -55,9 +55,9 @@
|
||||
--ink: #100f0e; /* page */
|
||||
--ash: #161413; /* recessed panel (chapter form) */
|
||||
--dim: #0d0c0b; /* archived rows sink */
|
||||
--rule: #221f1d; /* hairline between sheets */
|
||||
--rule-soft: #1a1817; /* measure edges */
|
||||
--field-line: #2c2926;
|
||||
--rule: #221f1d; /* decorative hairlines — not interactive — hairline between sheets */
|
||||
--rule-soft: #1a1817; /* decorative hairlines — not interactive — measure edges */
|
||||
--field-line: #4a4540; /* ≈■ #4a4540 — interactive border, distinct from decorative --rule/--rule-soft */
|
||||
--hover: #1a1816;
|
||||
|
||||
--paper: #f2ece5; /* highest-contrast text, primary button */
|
||||
@@ -70,8 +70,8 @@
|
||||
--faint: #3a3733; /* meta separators */
|
||||
--faint-2: #57504b; /* cover monogram */
|
||||
|
||||
--ember: #e0452c; /* the only heat */
|
||||
--ember-wash: #1a1211; /* ember-tinted surface */
|
||||
--ember: #e85a41; /* the only heat — verified against --ember-wash, not --ink — ≈■ #e85a41 — verified 5.31:1 on --ember-wash #1c0f0d, not --ink */
|
||||
--ember-wash: #1c0f0d; /* ■ #1c0f0d — verified against this wash for is-new card/Recent/Updated/libswitch */
|
||||
--ember-ink: #150907; /* text on solid ember */
|
||||
--ember-soft: #eda798; /* text on ember wash */
|
||||
/* Destruction is hot but not ember: a duller oxblood, so a remove confirm is
|
||||
@@ -80,6 +80,7 @@
|
||||
--danger-wash: #211311;
|
||||
--danger-ink: #150808;
|
||||
--danger-soft: #e2aaa1;
|
||||
--danger-line: #4a2e2b; /* resting hairline for Remove — muted danger, not alarm */
|
||||
--brass: #b8912f; /* favourite — a second, cooler metal */
|
||||
/* One accent per action, so a press says which lane it belongs to. All three
|
||||
are held at the same weight as --brass: muted, no ember competition. */
|
||||
@@ -92,7 +93,17 @@
|
||||
blue. Neither ember (new chapter) nor danger (destruction) may say
|
||||
"system unhealthy". */
|
||||
--patina: #5fb3a6;
|
||||
|
||||
--patina-wash: #12201e;
|
||||
--patina-soft: #9ad6cd;
|
||||
--patina-line: #1e3a37;
|
||||
--brass-wash: #231e0f;
|
||||
--brass-soft: #e0c495;
|
||||
--slate-wash: #14202a;
|
||||
--slate-soft: #a8bdd2;
|
||||
--clay-wash: #241a13;
|
||||
--clay-soft: #d9b79d;
|
||||
--moss-wash: #13211a;
|
||||
--moss-soft: #a9ceb1;
|
||||
/* Desktop cell borders for the two coloured action states. */
|
||||
--play-hot-line: #3a1d18;
|
||||
--fav-line: #332b14;
|
||||
@@ -116,6 +127,9 @@
|
||||
desktop, so both live here rather than as magic numbers. */
|
||||
--cover-w: 93px;
|
||||
--row-gap: 14px;
|
||||
/* The desktop scale-up lives in a token because `zoom` multiplies viewport
|
||||
units too: anything sized in vh has to divide it back out. */
|
||||
--zoom: 1;
|
||||
}
|
||||
|
||||
/* Light mode: same rules, cooler paper. Hues are re-tuned, not reused — the
|
||||
@@ -125,8 +139,8 @@
|
||||
--ink: #f7f4ef;
|
||||
--ash: #efeae3;
|
||||
--dim: #f1ede7;
|
||||
--rule: #e0dad2;
|
||||
--rule-soft: #e8e3dc;
|
||||
--rule: #e0dad2; /* decorative hairlines — not interactive */
|
||||
--rule-soft: #e8e3dc; /* decorative hairlines — not interactive */
|
||||
--field-line: #d4cdc4;
|
||||
--hover: #efeae3;
|
||||
|
||||
@@ -138,30 +152,42 @@
|
||||
--faint: #c9c2ba;
|
||||
--faint-2: #a8a098;
|
||||
|
||||
--ember: #c23a22;
|
||||
--ember-wash: #fbeee9;
|
||||
--ember: #c23a22; /* verified against --ember-wash, not --ink */
|
||||
--ember-wash: #fbeee9; /* verified against --ember-wash, not --ink — wash ground */
|
||||
--ember-ink: #fff;
|
||||
--ember-soft: #8d2c17;
|
||||
--ember-soft: #7c2314; /* from #8d2c17 — crimson not brown, 4.9:1 on #fbeee9 */
|
||||
--danger: #97362a;
|
||||
--danger-wash: #fbe9e5;
|
||||
--danger-ink: #fff;
|
||||
--danger-soft: #7c2c22;
|
||||
--danger-line: #d8b8b0; /* resting hairline for Remove — light counterpart to #4a2e2b */
|
||||
--brass: #8a681c;
|
||||
--slate: #3f6689;
|
||||
--moss: #3d6c46;
|
||||
--clay: #7c5533;
|
||||
--trash: #8c6558;
|
||||
--patina: #1f6f66;
|
||||
--patina-wash: #def3f0;
|
||||
--patina-soft: #144a43;
|
||||
--patina-line: #b8ddd8;
|
||||
--brass-wash: #fdf6e3;
|
||||
--brass-soft: #5a4812;
|
||||
--slate-wash: #e8eef5;
|
||||
--slate-soft: #24445f;
|
||||
--clay-wash: #fdf0e6;
|
||||
--clay-soft: #5a3520;
|
||||
--moss-wash: #e7f3e8;
|
||||
--moss-soft: #23482a;
|
||||
|
||||
--play-hot-line: #f0cfc6;
|
||||
--fav-line: #e3d3a4;
|
||||
|
||||
--asura: #4f6b80;
|
||||
--demonic: #8a6a55;
|
||||
--comix: #5f7250;
|
||||
--demonic: #7a5c4a; /* from #8a6a55 — 5.1:1 on #f7f4ef, ~4.7:1 on #efeae3 */
|
||||
--comix: #5a6c4b; /* from #5f7250 — 5.2:1 on #f7f4ef, 4.77:1 on #efeae3 — was 4.38:1 on ash, fails AA */
|
||||
--kagane: #6f5f7d;
|
||||
--novelfull: #7d6f4f;
|
||||
--lightnovelworld: #4f7d70;
|
||||
--novelfull: #6f5f3f; /* from #7d6f4f — same bar */
|
||||
--lightnovelworld: #3d6b5e; /* from #4f7d70 — lifts 3.90→4.8:1 on ash */
|
||||
|
||||
--hatch: repeating-linear-gradient(135deg, #e6e0d8 0 5px, #efeae3 5px 10px);
|
||||
--hatch-dim: repeating-linear-gradient(135deg, #ebe6de 0 5px, #f2eee8 5px 10px);
|
||||
@@ -178,6 +204,11 @@ body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
/* Every shell sets viewport-fit=cover, which puts the sheet's own hairline
|
||||
borders and its 20px gutter under a landscape cutout on a notched phone.
|
||||
Zero on every other device, so it costs nothing to honour here. */
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
@@ -186,6 +217,19 @@ button { cursor: pointer; }
|
||||
|
||||
/* Every hideable thing here is a flex container, and display beats hidden. */
|
||||
[hidden] { display: none !important; }
|
||||
/* Visually hidden but still announced — the live region for mutation and
|
||||
filter results. Display:none would silence it. */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Ends flush with the right edge of the card (30% wide × 233% travel), so the
|
||||
card itself never needs overflow: hidden to contain it. */
|
||||
@@ -203,6 +247,9 @@ button { cursor: pointer; }
|
||||
width: 100%;
|
||||
max-width: var(--measure);
|
||||
min-height: 100vh;
|
||||
/* The URL bar is inside 100vh on mobile Chromium, so the flush bottom
|
||||
border sits under it. dvh is the visible box; vh above is the fallback. */
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--rule-soft);
|
||||
@@ -248,6 +295,15 @@ button { cursor: pointer; }
|
||||
.ghost:hover { color: var(--paper); border-bottom-color: var(--paper); }
|
||||
/* The label is 15px tall by design; the thumb gets 44 without moving it. */
|
||||
.ghost::after { content: ""; position: absolute; inset: -15px -12px; }
|
||||
/* Nav and standalone controls share one paper ring. The per-action cells in
|
||||
the card strip carry their own accent rings instead; everything here would
|
||||
otherwise fall back to the UA default, which is a bright blue on a palette
|
||||
tuned for a dark room. */
|
||||
.ghost:focus-visible,
|
||||
.tabs a:focus-visible,
|
||||
.libswitch a:focus-visible,
|
||||
.empty .clear-search:focus-visible,
|
||||
.login-card button:focus-visible { outline: 2px solid var(--paper); outline-offset: 2px; }
|
||||
|
||||
/* ---- userscript setup: collapsed by default, one hairline, no card ---- */
|
||||
.setup {
|
||||
@@ -261,7 +317,7 @@ button { cursor: pointer; }
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
font: 500 11px/1 var(--font-mono);
|
||||
letter-spacing: .2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute-2);
|
||||
@@ -297,7 +353,21 @@ button { cursor: pointer; }
|
||||
}
|
||||
|
||||
|
||||
.chrome { display: flex; flex-direction: column; }
|
||||
/* Sticky: the buckets and the search box are how a large library is triaged,
|
||||
and a phone three screens in otherwise has to scroll back to the top to
|
||||
reach either. The keyrow below is deliberately left to scroll away — it
|
||||
teaches the icon strip once, and 150px of permanent chrome on an 844px
|
||||
phone costs more than re-scrolling for a reminder does. */
|
||||
.chrome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
/* Cards are position: relative, so without this they paint over it. */
|
||||
z-index: 2;
|
||||
background: var(--ink);
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.searchbar {
|
||||
display: flex;
|
||||
@@ -308,8 +378,9 @@ button { cursor: pointer; }
|
||||
border-bottom: 1px solid var(--rule);
|
||||
color: var(--mute-2);
|
||||
}
|
||||
/* The input drops its own outline, so the bar it sits in carries the focus
|
||||
ring — same move the two other inputs make with their border. */
|
||||
/* The bar carries a resting recolour on focus-within, and the input carries
|
||||
its own ring: the 1px border change alone is a weaker indicator than every
|
||||
other control on the sheet gets, on the control most worth finding. */
|
||||
.searchbar:focus-within { border-bottom-color: var(--paper); color: var(--paper); }
|
||||
.searchbar svg { width: 15px; height: 15px; flex: none; }
|
||||
.search {
|
||||
@@ -320,8 +391,8 @@ button { cursor: pointer; }
|
||||
background: transparent;
|
||||
color: var(--paper);
|
||||
font: 400 15px var(--font-body);
|
||||
outline: none;
|
||||
}
|
||||
.search:focus-visible { outline: 2px solid var(--paper); outline-offset: 3px; }
|
||||
.search::placeholder { color: var(--mute-2); }
|
||||
|
||||
/* Tabs are set in the display serif and underlined, not chipped. */
|
||||
@@ -333,6 +404,9 @@ button { cursor: pointer; }
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
/* Right-edge fade — the pills and 4th tab crop invisibly without it. */
|
||||
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
|
||||
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
|
||||
}
|
||||
.tabs::-webkit-scrollbar { display: none; }
|
||||
.tabs a {
|
||||
@@ -348,21 +422,25 @@ button { cursor: pointer; }
|
||||
font: 400 17px var(--font-display);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tabs a:hover { color: var(--paper-dim); }
|
||||
.tabs a:hover { color: var(--paper-dim); background: var(--hover); border-radius: 2px 2px 0 0; }
|
||||
.tabs a.active {
|
||||
color: var(--paper);
|
||||
border-bottom: 2px solid var(--paper);
|
||||
margin-bottom: -1px;
|
||||
background: var(--hover);
|
||||
}
|
||||
/* Updated is the one tab that carries heat. */
|
||||
.tabs .tab-new { color: var(--ember); }
|
||||
.tabs .tab-new.active { border-bottom-color: var(--ember); }
|
||||
.tabs .tab-new.active { border-bottom-color: var(--ember); background: var(--ember-wash); color: var(--ember); }
|
||||
.tabs .tab-new:hover { background: var(--ember-wash); color: var(--ember); }
|
||||
.count {
|
||||
font: 600 10px var(--font-mono);
|
||||
letter-spacing: 0;
|
||||
padding: 2px 5px;
|
||||
border: 1px solid currentColor;
|
||||
background: var(--ink);
|
||||
}
|
||||
.tab-new .count { background: var(--ember-wash); }
|
||||
|
||||
/* ---- action key: one permanent line under the tabs, so the icon strip below
|
||||
never has to be guessed at. Lean on a phone (28px, edge to edge), a step
|
||||
@@ -386,12 +464,18 @@ button { cursor: pointer; }
|
||||
}
|
||||
.keyrow .pair svg { width: 14px; height: 14px; flex: none; }
|
||||
.keyrow .pair span {
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
font: 500 11px/1 var(--font-mono);
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.keyrow .pair.brass svg { color: var(--brass); }
|
||||
.keyrow .pair.slate svg { color: var(--slate); }
|
||||
.keyrow .pair.clay svg { color: var(--clay); }
|
||||
.keyrow .pair.trash svg { color: var(--trash); }
|
||||
.keyrow .pair.brass span { color: var(--brass); }
|
||||
.keyrow .pair.slate span { color: var(--slate); }
|
||||
.keyrow .pair.clay span { color: var(--clay); }
|
||||
.keyrow .pair.trash span { color: var(--trash); }
|
||||
|
||||
/* ---- continue reading ---- */
|
||||
.recent {
|
||||
@@ -404,7 +488,7 @@ button { cursor: pointer; }
|
||||
.recent h2 {
|
||||
margin: 0;
|
||||
padding: 0 20px;
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
font: 500 11px/1 var(--font-mono);
|
||||
letter-spacing: .2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute-2);
|
||||
@@ -416,6 +500,10 @@ button { cursor: pointer; }
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
padding: 0 20px 4px;
|
||||
scroll-snap-type: x proximity;
|
||||
/* Static right-edge fade — newest card at 93px+GAP is otherwise invisible at 360px. */
|
||||
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
|
||||
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
|
||||
}
|
||||
.recent-strip::-webkit-scrollbar { display: none; }
|
||||
.recent-card {
|
||||
@@ -424,6 +512,7 @@ button { cursor: pointer; }
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
.recent-cover {
|
||||
position: relative;
|
||||
@@ -434,6 +523,11 @@ button { cursor: pointer; }
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Cover and monogram share one grid cell rather than stacking: the monogram is
|
||||
the fallback for a cover that fails to load, not only for one that was never
|
||||
acquired, and an image that fails to decode otherwise renders the browser's
|
||||
broken-image glyph over the hatch. */
|
||||
.recent-cover > img, .recent-cover > .monogram { grid-area: 1 / 1; }
|
||||
.recent-cover img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.recent-title {
|
||||
font: 400 16px/1.25 var(--font-display);
|
||||
@@ -442,6 +536,7 @@ button { cursor: pointer; }
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.recent-chapter {
|
||||
font: 500 11px/1 var(--font-mono);
|
||||
@@ -450,6 +545,7 @@ button { cursor: pointer; }
|
||||
}
|
||||
.recent-card.is-new .recent-title { color: var(--paper-hot); }
|
||||
.recent-card.is-new .recent-chapter { color: var(--ember); }
|
||||
.recent-card.is-new .recent-cover { outline: 1px solid color-mix(in oklch, var(--ember) 26%, transparent); outline-offset: -1px; }
|
||||
|
||||
/* The rule at the cover foot is the only cover ornament: ember for a new
|
||||
chapter, brass for a favourite. */
|
||||
@@ -481,6 +577,11 @@ button { cursor: pointer; }
|
||||
animation: sheetIn .18s ease-out;
|
||||
}
|
||||
.card.is-dim { background: var(--dim); }
|
||||
.card.is-new {
|
||||
background: var(--ember-wash);
|
||||
border-bottom-color: color-mix(in oklch, var(--ember) 14%, var(--rule));
|
||||
}
|
||||
.card.is-new .cover { outline: 1px solid color-mix(in oklch, var(--ember) 22%, transparent); outline-offset: -1px; }
|
||||
|
||||
.row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--row-gap); }
|
||||
|
||||
@@ -494,9 +595,12 @@ button { cursor: pointer; }
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cover > img, .cover > .monogram { grid-area: 1 / 1; }
|
||||
.cover img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.cover .monogram { font-size: 32px; }
|
||||
.is-dim .cover { background: var(--hatch-dim); filter: grayscale(1); opacity: .85; }
|
||||
.is-dim .cover { background: var(--hatch-dim); }
|
||||
.is-dim .cover img { filter: grayscale(1); opacity: .85; }
|
||||
.is-dim .cover .monogram { color: var(--mute-2); }
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
@@ -514,6 +618,17 @@ button { cursor: pointer; }
|
||||
min-width: 0;
|
||||
font: 400 21px/1.2 var(--font-display);
|
||||
color: var(--paper-dim);
|
||||
/* Titles come from a third party's og:title. One unbroken 90-character
|
||||
token measured 789px of document on a 390px viewport — the whole sheet
|
||||
scrolled sideways — so every slot that prints one breaks mid-word. */
|
||||
overflow-wrap: anywhere;
|
||||
/* Three lines, so one scraped 90-character title cannot set the rhythm of
|
||||
the whole list. Deeper than the strip's two: this is where the title is
|
||||
the primary identifier, not a thumbnail caption. */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Heat: crimson title over an ember hairline sized to the text, not the row. */
|
||||
.is-new .title {
|
||||
@@ -522,6 +637,9 @@ button { cursor: pointer; }
|
||||
color: var(--paper-hot);
|
||||
border-bottom: 1px solid var(--ember);
|
||||
padding-bottom: 3px;
|
||||
/* -webkit-box fills its line box, which would stretch the ember hairline to
|
||||
the full row; the underline is sized to the text. */
|
||||
width: fit-content;
|
||||
}
|
||||
.is-dim .title { font-style: italic; color: var(--mute); }
|
||||
.is-dim .fav-mark { color: var(--mute-2); }
|
||||
@@ -565,7 +683,7 @@ button { cursor: pointer; }
|
||||
}
|
||||
.libswitch a {
|
||||
padding: 7px 13px;
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
font: 500 11px/1 var(--font-mono);
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute);
|
||||
@@ -615,23 +733,34 @@ button { cursor: pointer; }
|
||||
.actions > *:hover { color: var(--paper); }
|
||||
/* Per-action accent on hover and press: gold favourite, slate archive, clay
|
||||
chapter. Remove keeps --danger, play keeps paper/ember. */
|
||||
.actions .fav:hover, .actions .fav:active, .actions .fav:focus-visible { color: var(--brass); }
|
||||
.actions .pencil:hover, .actions .pencil:active, .actions .pencil:focus-visible { color: var(--clay); }
|
||||
.actions .box:hover, .actions .box:active, .actions .box:focus-visible { color: var(--slate); }
|
||||
.actions .fav:hover, .actions .fav:active, .actions .fav:focus-visible { color: var(--brass); background: var(--brass-wash); }
|
||||
.actions .fav:focus-visible { outline: 2px solid var(--brass); outline-offset: -2px; }
|
||||
.actions .pencil:hover, .actions .pencil:active, .actions .pencil:focus-visible { color: var(--clay); background: var(--clay-wash); }
|
||||
.actions .pencil:focus-visible { outline: 2px solid var(--clay); outline-offset: -2px; }
|
||||
.actions .box:hover, .actions .box:active, .actions .box:focus-visible { color: var(--slate); background: var(--slate-wash); }
|
||||
.actions .box:focus-visible { outline: 2px solid var(--slate); outline-offset: -2px; }
|
||||
.actions .play { color: var(--paper); }
|
||||
|
||||
.is-new .actions .play { color: var(--ember); }
|
||||
.actions .play:hover { background: var(--hover); }
|
||||
.actions .on { color: var(--brass); }
|
||||
.is-new .actions .play:hover { background: var(--ember-wash); color: var(--ember); }
|
||||
.actions .play:focus-visible { background: var(--hover); outline: 2px solid var(--paper); outline-offset: -2px; }
|
||||
.is-new .actions .play:focus-visible { background: var(--ember-wash); color: var(--ember); outline-color: var(--ember); }
|
||||
.actions .on { color: var(--brass); background: var(--brass-wash); }
|
||||
.actions .restore { color: var(--paper); }
|
||||
.actions .remove { color: var(--trash); }
|
||||
.actions .remove:hover { color: var(--danger); }
|
||||
.actions .restore:hover, .actions .restore:active, .actions .restore:focus-visible { color: var(--slate); background: var(--slate-wash); }
|
||||
.actions .restore:focus-visible { outline: 2px solid var(--slate); outline-offset: -2px; }
|
||||
.actions .remove { color: var(--trash); box-shadow: inset 1px 0 0 var(--danger-line); }
|
||||
.actions .remove:hover, .actions .remove:active, .actions .remove:focus-visible { color: var(--danger); background: var(--danger-wash); }
|
||||
.actions .remove:focus-visible { outline: 2px solid var(--danger); outline-offset: -2px; }
|
||||
.actions .open { background: var(--hover); color: var(--paper); }
|
||||
/* Lifecycle cells already sit on --ash, which is what --hover resolves to, so
|
||||
an open one needs the next step up to stay legible as the panel's owner. */
|
||||
.actions .lifecycle.open { background: var(--rule); }
|
||||
.actions .lifecycle.open.box, .actions .lifecycle.open.box:hover { background: var(--slate-wash); color: var(--slate); }
|
||||
.actions .remove.open { background: var(--danger-wash); color: var(--danger); }
|
||||
.is-dim .actions > * { color: var(--mute-2); }
|
||||
.is-dim .actions > *:hover { background: transparent; color: var(--mute); }
|
||||
|
||||
/* Two clusters by consequence: navigate (play) | organize (favourite,
|
||||
chapter) | lifecycle (archive/restore, remove). The lifecycle cells
|
||||
@@ -654,7 +783,7 @@ button { cursor: pointer; }
|
||||
}
|
||||
.chapter-form .hint {
|
||||
margin: 0;
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
font: 500 11px/1 var(--font-mono);
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute-2);
|
||||
@@ -671,9 +800,10 @@ button { cursor: pointer; }
|
||||
font: 500 17px var(--font-mono);
|
||||
outline: none;
|
||||
}
|
||||
/* Focus follows the .searchbar idiom — paper, not heat: a red border on a
|
||||
valid number field reads as "invalid". */
|
||||
.chapter-form input:focus { border-color: var(--paper); }
|
||||
/* Clay carries the chapter action -- paper flashes on a dimmed OLED and ember
|
||||
reads as "invalid" on a valid number. */
|
||||
.chapter-form input:focus { border-color: var(--clay); }
|
||||
.chapter-form input:focus-visible { border-color: var(--clay); outline: 2px solid var(--clay); outline-offset: 2px; }
|
||||
.chapter-form input::-webkit-outer-spin-button,
|
||||
.chapter-form input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
.chapter-form button {
|
||||
@@ -684,6 +814,7 @@ button { cursor: pointer; }
|
||||
color: var(--ink);
|
||||
font: 400 17px var(--font-display);
|
||||
}
|
||||
.chapter-form button:focus-visible { outline: 2px solid var(--clay); outline-offset: 2px; }
|
||||
|
||||
.confirm-row {
|
||||
display: flex;
|
||||
@@ -696,23 +827,25 @@ button { cursor: pointer; }
|
||||
}
|
||||
/* The remove question names the series, so it has to be able to take the row
|
||||
to itself and push the buttons onto their own line. */
|
||||
.confirm-row span { flex: 1 1 16ch; font: 400 17px/1.3 var(--font-display); color: var(--danger-soft); }
|
||||
.confirm-row span { flex: 1 1 16ch; font: 400 17px/1.3 var(--font-display); color: var(--danger-soft); overflow-wrap: anywhere; }
|
||||
.confirm-row div { display: flex; flex: none; gap: 12px; margin-left: auto; }
|
||||
.confirm-row button {
|
||||
height: 46px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--field-line);
|
||||
background: transparent;
|
||||
color: var(--mute);
|
||||
background: var(--ash);
|
||||
color: var(--paper-dim);
|
||||
font: 500 12px var(--font-body);
|
||||
}
|
||||
.confirm-row button:hover { color: var(--paper); }
|
||||
.confirm-row button:hover { color: var(--paper); border-color: var(--paper); }
|
||||
.confirm-row button:focus-visible { color: var(--paper); border-color: var(--paper); outline: 2px solid var(--paper); outline-offset: 2px; }
|
||||
.confirm-row .danger-solid {
|
||||
border: none;
|
||||
background: var(--danger);
|
||||
color: var(--danger-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.confirm-row .danger-solid:focus-visible { outline-color: var(--danger); }
|
||||
/* Archive is reversible, so its confirm asks in grey — only the
|
||||
irreversible remove gets the danger wash. */
|
||||
.confirm-row.calm { background: var(--ash); }
|
||||
@@ -723,16 +856,31 @@ button { cursor: pointer; }
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.confirm-row .go:hover { color: var(--ink); }
|
||||
.confirm-row .go:hover, .confirm-row .go:focus-visible { color: var(--ink); }
|
||||
|
||||
/* The same notice in two positions: .error-inline belongs to one card and
|
||||
reports a write that failed, .notice sits under the chrome and reports the
|
||||
requests that have no card to belong to — a tab switch, a rotation. */
|
||||
.error-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 0;
|
||||
padding: 11px 13px;
|
||||
background: var(--danger-wash);
|
||||
border-left: 2px solid var(--danger);
|
||||
font: 400 16px var(--font-display);
|
||||
color: var(--danger-soft);
|
||||
}
|
||||
.notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 0;
|
||||
padding: 11px 20px;
|
||||
background: var(--ash);
|
||||
border-left: 2px solid var(--mute);
|
||||
border-left: 2px solid var(--patina);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
font: 400 16px var(--font-display);
|
||||
color: var(--paper-dim);
|
||||
}
|
||||
@@ -742,13 +890,22 @@ button { cursor: pointer; }
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--mute);
|
||||
background: var(--danger);
|
||||
}
|
||||
.notice::before {
|
||||
content: "";
|
||||
flex: none;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--patina);
|
||||
animation: mutePulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.error-inline .error-link {
|
||||
.error-inline .error-link, .notice .error-link {
|
||||
border-bottom: 1px solid var(--field-line);
|
||||
color: var(--paper);
|
||||
}
|
||||
.error-inline .error-link { border-bottom-color: color-mix(in oklch, var(--danger) 26%, transparent); color: var(--danger-soft); }
|
||||
|
||||
/* Busy: a grey hairline slides across the top of the sheet — deliberately not
|
||||
ember, which on a list screen only ever means "new chapter". */
|
||||
@@ -763,6 +920,22 @@ button { cursor: pointer; }
|
||||
background: var(--mute);
|
||||
animation: barSlide 1.15s linear infinite;
|
||||
}
|
||||
/* Past about two seconds the bar alone stops reading as "working" and starts
|
||||
reading as "ignored" — and htmx's own timeout is 15s. Delayed rather than
|
||||
immediate: every response on a live connection lands well inside it. */
|
||||
@keyframes busyWord { to { opacity: 1 } }
|
||||
.card.htmx-request::after {
|
||||
content: "Saving…";
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 20px;
|
||||
font: 500 11px/1 var(--font-mono);
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute);
|
||||
opacity: 0;
|
||||
animation: busyWord 0s 2s forwards;
|
||||
}
|
||||
|
||||
/* ---- empty ---- */
|
||||
.empty {
|
||||
@@ -771,7 +944,7 @@ button { cursor: pointer; }
|
||||
gap: 7px;
|
||||
padding: 40px 20px 48px;
|
||||
}
|
||||
.empty strong { font: 400 20px var(--font-display); color: var(--paper); }
|
||||
.empty strong { font: 400 20px var(--font-display); color: var(--paper); overflow-wrap: anywhere; }
|
||||
.empty .clear-search {
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
@@ -795,21 +968,26 @@ button { cursor: pointer; }
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
min-height: 100vh;
|
||||
min-height: calc(100vh / var(--zoom));
|
||||
min-height: calc(100dvh / var(--zoom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 52px 28px 40px;
|
||||
}
|
||||
.login-card .eyebrow {
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
font: 500 11px/1 var(--font-mono);
|
||||
letter-spacing: .2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute-2);
|
||||
}
|
||||
.login-card h1 {
|
||||
margin: 14px 0 0;
|
||||
font: 400 44px/1 var(--font-display);
|
||||
/* The wordmark is the widest unbreakable run in the app; at 44px it is
|
||||
wider than a 360px phone, and the page has no other content to scroll
|
||||
to, so the overflow reads as a broken page. Fluid to the measured fit
|
||||
(30px clears a 320px viewport), locked at the design size from 520px up. */
|
||||
font: 400 clamp(30px, 8.5vw, 44px)/1 var(--font-display);
|
||||
letter-spacing: -.01em;
|
||||
color: var(--paper);
|
||||
}
|
||||
@@ -860,7 +1038,7 @@ button { cursor: pointer; }
|
||||
keeps every proportion — hairlines, cover ratios, hit targets —
|
||||
intact. ---- */
|
||||
@media (min-width: 1280px) {
|
||||
:root { zoom: 1.2; }
|
||||
:root { --zoom: 1.2; zoom: var(--zoom); }
|
||||
}
|
||||
|
||||
/* ---- desktop: same measure, actions fold up beside the row ---- */
|
||||
@@ -880,6 +1058,7 @@ button { cursor: pointer; }
|
||||
}
|
||||
.tabs { order: 1; flex: none; gap: 20px; padding: 0; border-bottom: none; }
|
||||
.tabs a { padding: 10px 0 14px; font-size: 18px; }
|
||||
.tabs, .recent-strip { -webkit-mask-image: none; mask-image: none; }
|
||||
.searchbar { order: 2; flex: 1; margin: 0; border-bottom: none; }
|
||||
.recent h2, .recent-strip { padding-left: 32px; padding-right: 32px; }
|
||||
.keyrow {
|
||||
@@ -891,8 +1070,7 @@ button { cursor: pointer; }
|
||||
}
|
||||
.keyrow .pair { flex-direction: row; gap: 7px; }
|
||||
.keyrow .pair svg { width: 14px; height: 14px; }
|
||||
.keyrow .pair span { font-size: 11px; letter-spacing: .1em; }
|
||||
.keyrow .full { display: inline; }
|
||||
.keyrow .pair span { letter-spacing: .1em; }
|
||||
.recent-card, .recent-cover { width: var(--cover-w); }
|
||||
.recent-cover { height: calc(var(--cover-w) * 4 / 3); }
|
||||
.recent-title { font-size: 17px; }
|
||||
@@ -913,6 +1091,7 @@ button { cursor: pointer; }
|
||||
than by the hairline the phone layout uses. */
|
||||
.actions > .play + *,
|
||||
.actions > *:not(.lifecycle) + .lifecycle { margin-left: 10px; box-shadow: none; }
|
||||
.actions .remove { box-shadow: none; border-left-color: var(--danger-line); }
|
||||
.is-new .actions .play { border-color: var(--play-hot-line); }
|
||||
.actions .on { border-color: var(--fav-line); }
|
||||
/* The cell border follows the icon on hover, so the accent reads as a state
|
||||
@@ -922,6 +1101,19 @@ button { cursor: pointer; }
|
||||
.chapter-form, .confirm-row, .error-inline {
|
||||
margin-left: calc(var(--cover-w) + var(--row-gap));
|
||||
}
|
||||
/* Not the panel indent: the notice is chrome, so it keeps the sheet gutter
|
||||
every other full-width row uses. */
|
||||
.notice { padding-left: 32px; padding-right: 32px; }
|
||||
}
|
||||
|
||||
/* Every other control on this sheet is already 44 or borrows a .ghost::after
|
||||
to get there; the library switch is the one sized by its own padding in both
|
||||
layouts, and padding plus an 11px line lands at 43. min-height states the
|
||||
target instead of arithmetic on the font. Gated on the pointer rather than
|
||||
the width — a touch laptop lands on the desktop layout with the same
|
||||
thumb. */
|
||||
@media (pointer: coarse) {
|
||||
.libswitch a { min-height: 44px; display: grid; place-items: center; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -929,4 +1121,7 @@ button { cursor: pointer; }
|
||||
busy bar and error dot are both ::before. Their static form still reads:
|
||||
the bar stays drawn and .actions stays dimmed. */
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
/* The busy word is revealed by a zero-duration delayed animation, which the
|
||||
rule above cancels — show it from the start instead. */
|
||||
.card.htmx-request::after { opacity: 1; }
|
||||
}
|
||||
|
||||
@@ -7,12 +7,17 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
{{/* Same request timeout as the library shell: the Lanes fragment refreshes
|
||||
itself every 30s, so an untimed hung request stacks. */}}
|
||||
<meta name="htmx-config" content='{"timeout":15000}'>
|
||||
<title>BookmarkManager — Admin</title>
|
||||
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/admin.css">
|
||||
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/static/fonts/dm-sans-var-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
<script src="/static/filter.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sheet admin-sheet">
|
||||
@@ -33,9 +38,14 @@
|
||||
<a href="/admin/series" class="{{if or (eq .Page "series") (eq .Page "series-detail")}}active{{end}}" {{if or (eq .Page "series") (eq .Page "series-detail")}}aria-current="page"{{end}}>Series</a>
|
||||
</nav>
|
||||
|
||||
{{/* Where a failure with no specific slot gets reported — same slot the
|
||||
reader library uses under its chrome. filter.js fills it. */}}
|
||||
<p class="notice" id="notice" role="status" hidden></p>
|
||||
<div id="sr-announce" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
|
||||
<main class="page admin-page">
|
||||
{{if eq .Page "lanes"}}
|
||||
<div aria-live="polite">{{template "lanes" .Lanes}}</div>
|
||||
{{template "lanes" .Lanes}}
|
||||
{{else if eq .Page "readers"}}
|
||||
{{template "readers" .}}
|
||||
{{else if eq .Page "series"}}
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
{{/* htmx defaults to no timeout, so a phone that walks into a dead zone
|
||||
leaves the XHR open forever and the card it came from keeps its busy bar
|
||||
and its disabled actions until a reload. 15s is well past any response
|
||||
this deployment produces. */}}
|
||||
<meta name="htmx-config" content='{"timeout":15000}'>
|
||||
<title>BookmarkManager</title>
|
||||
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
@@ -53,34 +58,39 @@
|
||||
<a href="{{.PageURL "all"}}" class="{{if eq .Tab "all"}}active{{end}}"
|
||||
{{if eq .Tab "all"}}aria-current="page"{{end}}
|
||||
hx-get="{{.ListURL "all"}}" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="{{.PageURL "all"}}" hx-on::after-request="setActiveTab(this)">All</a>
|
||||
hx-push-url="{{.PageURL "all"}}" hx-on::after-request="if (event.detail.successful) setActiveTab(this)">All</a>
|
||||
{{/* The one bucket novels do not have: without a poller-fed "what is out
|
||||
that I have not read", the tab would only ever restate All. */}}
|
||||
{{if eq .Lib "manga"}}
|
||||
<a href="{{.PageURL "new"}}" class="tab-new {{if eq .Tab "new"}}active{{end}}"
|
||||
{{if eq .Tab "new"}}aria-current="page"{{end}}
|
||||
hx-get="{{.ListURL "new"}}" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="{{.PageURL "new"}}" hx-on::after-request="setActiveTab(this)">Updated
|
||||
hx-push-url="{{.PageURL "new"}}" hx-on::after-request="if (event.detail.successful) setActiveTab(this)">Updated
|
||||
{{template "newcount" .}}</a>
|
||||
{{end}}
|
||||
<a href="{{.PageURL "fav"}}" class="{{if eq .Tab "fav"}}active{{end}}"
|
||||
{{if eq .Tab "fav"}}aria-current="page"{{end}}
|
||||
hx-get="{{.ListURL "fav"}}" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="{{.PageURL "fav"}}" hx-on::after-request="setActiveTab(this)">Favourites</a>
|
||||
hx-push-url="{{.PageURL "fav"}}" hx-on::after-request="if (event.detail.successful) setActiveTab(this)">Favourites</a>
|
||||
<a href="{{.PageURL "archived"}}" class="{{if eq .Tab "archived"}}active{{end}}"
|
||||
{{if eq .Tab "archived"}}aria-current="page"{{end}}
|
||||
hx-get="{{.ListURL "archived"}}" hx-target="#list" hx-swap="innerHTML"
|
||||
hx-push-url="{{.PageURL "archived"}}" hx-on::after-request="setActiveTab(this)">Archived</a>
|
||||
hx-push-url="{{.PageURL "archived"}}" hx-on::after-request="if (event.detail.successful) setActiveTab(this)">Archived</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{{/* Where a failure with no card to sit in gets reported: a tab switch, a
|
||||
rotation, an expired session. filter.js fills it and unhides it. */}}
|
||||
<p class="notice" id="notice" role="status" hidden></p>
|
||||
<div id="sr-announce" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
|
||||
{{template "setup" .}}
|
||||
|
||||
{{template "keyrow" .}}
|
||||
|
||||
{{template "recent" .}}
|
||||
|
||||
<main id="list" class="list">
|
||||
<main id="list" class="list" tabindex="-1">
|
||||
{{template "list" .}}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -6,11 +6,17 @@
|
||||
<div class="row">
|
||||
<a class="cover" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer"
|
||||
tabindex="-1" aria-hidden="true">
|
||||
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
|
||||
{{/* aria-hidden on the cover link is not enough — Chromium still exposes
|
||||
the letter because the link is programmatically focusable — so the
|
||||
monogram carries its own, same as the recent strip's. */}}
|
||||
{{else}}<span class="monogram" aria-hidden="true">{{.Initial}}</span>{{end}}
|
||||
monogram carries its own, same as the recent strip's.
|
||||
|
||||
The monogram is always in the DOM under the image, not only when no
|
||||
cover was acquired: a stored cover that 404s or a phone that drops
|
||||
the request otherwise leaves the browser's broken-image glyph in a
|
||||
93px slot. onerror removes the image and the letter is already
|
||||
there. */}}
|
||||
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy" onerror="this.remove()">{{end}}
|
||||
<span class="monogram" aria-hidden="true">{{.Initial}}</span>
|
||||
{{if and (eq .Status "reading") .HasNewChapter}}<span class="foot-rule"></span>
|
||||
{{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
|
||||
</a>
|
||||
@@ -46,13 +52,14 @@
|
||||
</a>
|
||||
<button class="fav{{if .Favorite}} on{{end}}"
|
||||
title="{{if .Favorite}}Remove from favourites{{else}}Add to favourites{{end}}"
|
||||
aria-label="Toggle favourite"
|
||||
aria-label="{{if .Favorite}}Remove from favourites{{else}}Add to favourites{{end}}"
|
||||
aria-pressed="{{if .Favorite}}true{{else}}false{{end}}"
|
||||
hx-post="/ui/bookmarks/{{.Key}}/favorite"
|
||||
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
|
||||
hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="this">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-star{{if .Favorite}}-on{{end}}"/></svg>
|
||||
</button>
|
||||
<button class="pencil" title="Set chapter" aria-label="Set chapter"
|
||||
<button class="pencil" title="Set chapter" aria-label="Set chapter" aria-expanded="false" aria-controls="chapter-form-{{.Key}}"
|
||||
onclick="toggleChapterForm('{{.Key}}')">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg>
|
||||
</button>
|
||||
@@ -88,8 +95,11 @@
|
||||
label names the field and the latest sits after it as context. */}}
|
||||
<label class="hint" for="chapter-{{.Key}}">Chapter you're on</label>
|
||||
<div class="field">
|
||||
{{/* max is a fat-finger guard, not a real ceiling — no series is near it. */}}
|
||||
<input id="chapter-{{.Key}}" name="chapter" type="number" step="0.1" min="0" max="9999"
|
||||
{{/* max is a fat-finger guard, not a real ceiling — no series is near it.
|
||||
step is "any" because the server accepts any float and the field is
|
||||
pre-filled from it: under step=0.1 a series read to 1200.25 opened a
|
||||
form that refused its own value. */}}
|
||||
<input id="chapter-{{.Key}}" name="chapter" type="number" step="any" min="0" max="9999"
|
||||
value="{{.LastChapterNum}}" required>
|
||||
<button type="submit">Save</button>
|
||||
</div>
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
<a class="recent-card {{if .HasNewChapter}}is-new{{end}}" href="{{.ContinueURL}}"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
<span class="recent-cover">
|
||||
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
|
||||
{{else}}<span class="monogram" aria-hidden="true">{{.Initial}}</span>{{end}}
|
||||
{{/* Monogram always present under the image; see card.html. */}}
|
||||
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy" onerror="this.remove()">{{end}}
|
||||
<span class="monogram" aria-hidden="true">{{.Initial}}</span>
|
||||
{{if .HasNewChapter}}<span class="foot-rule"></span>
|
||||
{{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
|
||||
</span>
|
||||
@@ -34,11 +35,11 @@
|
||||
<div class="keyrow" id="keyrow" aria-label="Action key"{{if .OOB}} hx-swap-oob="true"{{end}}>
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-play"/></svg><span>Read</span></span>
|
||||
<span class="pair brass"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-star"/></svg><span>Fav</span></span>
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg><span>Chapter</span></span>
|
||||
<span class="pair clay"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg><span>Chapter</span></span>
|
||||
{{if eq .Tab "archived"}}
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg><span>Restore</span></span>
|
||||
<span class="pair slate"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg><span>Restore</span></span>
|
||||
{{else}}
|
||||
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-box"/></svg><span>Archive</span></span>
|
||||
<span class="pair slate"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-box"/></svg><span>Archive</span></span>
|
||||
{{end}}
|
||||
<span class="pair trash"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-trash"/></svg><span>Delete</span></span>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
this template only prints them. */}}
|
||||
{{define "lanes"}}
|
||||
<section class="lanes" id="lanes"
|
||||
hx-get="/ui/admin/lanes" hx-trigger="every 30s" hx-swap="outerHTML">
|
||||
hx-get="/ui/admin/lanes" hx-trigger="every 30s" hx-swap="outerHTML" hx-sync="this:replace">
|
||||
<div class="sechead">
|
||||
<h2 class="sec">Poll Lanes</h2>
|
||||
<p class="statusline">
|
||||
@@ -18,20 +18,23 @@
|
||||
{{else}}Browser: {{if not .BrowserConfigured}}<span class="mark-faint">not configured</span>{{else if .BrowserReachable}}<span class="mark mark-strong">reachable</span>{{else}}<span class="mark bad">unreachable</span>{{end}}{{end}}
|
||||
</p>
|
||||
</div>
|
||||
<p class="hint">Healthy idle (paused, browser asleep, nothing eligible) needs no action — attention needs the owner.</p>
|
||||
{{if .Rows}}
|
||||
<div class="tbl lanes">
|
||||
<div class="thead">
|
||||
<span>Site</span><span>Due</span><span>Checked</span><span>Gap</span>
|
||||
<span>Last pass</span><span>Outcomes · state</span><span></span>
|
||||
<span>Last pass</span><span title="Window outcomes for this Site (last 12h)">Outcomes · status</span><span></span>
|
||||
</div>
|
||||
{{range .Rows}}
|
||||
<div class="trow{{if .Attention}} attention{{end}}">
|
||||
<span class="c-site">{{.Site}}</span>
|
||||
<span>{{.Due}}</span>
|
||||
<span>{{.Checked}}</span>
|
||||
<span>{{.Gap}}</span>
|
||||
<a class="c-site" href="{{.FailingHref}}">{{.Site}}</a>
|
||||
{{/* data-label is the column word for the phone layout, which drops the
|
||||
thead: three bare numbers in a row say nothing. */}}
|
||||
<span data-label="due">{{.Due}}</span>
|
||||
<span data-label="checked">{{.Checked}}</span>
|
||||
<span data-label="gap">{{.Gap}}</span>
|
||||
<span>ran {{.Ran}}</span>
|
||||
<span class="c-skip">{{if .HasChips}}{{range $i, $c := .Chips}}{{if $i}}<span class="mark-faint"> · </span>{{end}}<span class="mark">{{$c.Name}} {{$c.Count}}</span>{{end}}{{else}}<span class="mark-faint">none observed</span>{{end}}{{if .StatePhrase}} · <span class="{{if .StateGood}}ok{{else}}bad{{end}}">{{.StatePhrase}}</span>{{end}}</span>
|
||||
<span class="c-skip">{{if .HasChips}}{{range $i, $c := .Chips}}{{if $i}}<span class="mark-faint"> · </span>{{end}}<span class="mark">{{$c.Name}} {{$c.Count}}</span>{{end}}{{else}}<span class="mark-faint">none observed</span>{{end}}{{if .StatePhrase}} · <span class="{{if .StateGood}}ok{{else}}bad{{end}}" aria-label="{{if .StateGood}}healthy: {{end}}{{.StatePhrase}}">{{.StatePhrase}}</span>{{end}}</span>
|
||||
{{/* The pause control lives in the one slot the design leaves for it:
|
||||
a running Lane offers the three durations and Pause; a paused Lane
|
||||
offers Resume in the same place. Pause is not destruction — it
|
||||
@@ -39,11 +42,11 @@
|
||||
confirm row or the danger accent. The form wraps the select so the
|
||||
offered duration travels with the press. */}}
|
||||
<span class="c-ctrl">{{if .Paused}}<span class="pausebar">
|
||||
<form hx-post="/admin/lanes/{{.Site}}/resume" hx-target="#lanes" hx-swap="outerHTML">
|
||||
<form hx-post="/admin/lanes/{{.Site}}/resume" hx-target="#lanes" hx-swap="outerHTML" hx-indicator="#lanes" hx-disabled-elt="find button" hx-sync="closest #lanes:replace">
|
||||
<button type="submit" class="ghost">Resume</button>
|
||||
</form>
|
||||
</span>{{else}}<span class="pausebar">
|
||||
<form hx-post="/admin/lanes/{{.Site}}/pause" hx-target="#lanes" hx-swap="outerHTML">
|
||||
<form hx-post="/admin/lanes/{{.Site}}/pause" hx-target="#lanes" hx-swap="outerHTML" hx-indicator="#lanes" hx-disabled-elt="find button, select" hx-sync="closest #lanes:replace">
|
||||
<select name="duration" aria-label="Pause duration">
|
||||
<option>1h</option><option selected>6h</option><option>24h</option>
|
||||
</select>
|
||||
@@ -57,4 +60,4 @@
|
||||
<p class="empty">No data yet — no Lane has recorded a pass.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{{/* The client filter only hides cards, so without this the list area goes
|
||||
blank on a query that matches nothing. filter.js fills in the query and
|
||||
unhides it; it lives inside #list so a tab swap re-creates it. */}}
|
||||
<div class="empty" id="no-match" hidden>
|
||||
<div class="empty" id="no-match" role="status" hidden>
|
||||
<strong>No titles match “<span class="no-match-q"></span>”.</strong>
|
||||
<button type="button" class="clear-search">Clear search</button>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/static/fonts/dm-sans-var-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
</head>
|
||||
<body>
|
||||
<main class="login-card">
|
||||
@@ -25,7 +26,7 @@
|
||||
<p class="error" role="alert">{{.Error}}</p>
|
||||
<button type="submit">Continue with Discord</button>
|
||||
</form>
|
||||
<p class="login-note">Guild membership is required to sign in.</p>
|
||||
<p class="login-note">{{if .GuildName}}This library is for members of the {{.GuildName}} Discord. Ask a member for an invite to sign in.{{else}}This library is private to its Discord community. Ask a member for an invite to sign in.{{end}}</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
figures link, what a Lane's state means — is made in Go; this template
|
||||
only prints. */}}
|
||||
{{define "overview"}}
|
||||
<p class="verdict"><span class="attn">{{.Verdict}}</span> {{if .HasCounts}}<span class="counts">· <b>{{.Waiting}}</b> series waiting · <b>{{.Unchecked}}</b> unchecked over 12h</span>{{end}}</p>
|
||||
<p class="verdict"><span class="attn">{{.Verdict}}</span> {{if .HasCounts}}<span class="counts">· <b>{{.Waiting}}</b> series waiting · <b>{{.Unchecked}}</b> unchecked over 12h{{if ne .Verdict "all lanes healthy"}} · <a href="/admin/lanes">review Lanes</a>{{end}}</span>{{end}}</p>
|
||||
<h2 class="sec">Hygiene</h2>
|
||||
<div class="stats">{{range .Hygiene}}<div class="stat"><span class="lbl">{{.Label}}</span>{{if .Href}}<a class="fig" href="{{.Href}}">{{.Count}}</a>{{else}}<span class="fig zero">{{.Count}}</span>{{end}}</div>{{end}}</div>
|
||||
<h2 class="sec">Library</h2>
|
||||
<div class="stats">{{range .Library}}<div class="stat"><span class="lbl">{{.Label}}</span>{{if .Href}}<a class="fig" href="{{.Href}}">{{.Count}}</a>{{else}}<span class="fig zero">{{.Count}}</span>{{end}}</div>{{end}}</div>
|
||||
{{/* data-label carries each cell's column word for the phone layout, which
|
||||
drops the thead: the CSS prints it as the cell's prefix. */}}
|
||||
<h2 class="sec">Sites · last 12h</h2>
|
||||
<div class="tbl sites"><div class="thead"><span>Site</span><span>Series</span><span>No cover</span><span>Never chk</span><span>Stale</span><span>State</span></div>{{range .Sites}}<div class="trow"><a class="c-site site-{{.Site}}" href="{{.SiteHref}}">{{.Site}}</a>{{range .Figs}}{{if .Href}}<a class="fig" href="{{.Href}}">{{.Count}}</a>{{else}}<span class="fig zero">{{.Count}}</span>{{end}}{{end}}<span class="c-state{{if .StateGood}} ok{{end}}{{if .StateBad}} bad{{end}}">{{.State}}</span></div>{{end}}</div>
|
||||
{{if .Sites}}<div class="tbl sites"><div class="thead"><span>Site</span><span>Series</span><span>No cover</span><span>Never checked</span><span title="Stale: not checked in 12h window">Stale</span><span title="Lane status — same vocabulary as Poll Lanes">Status</span></div>{{range .Sites}}<div class="trow"><a class="c-site site-{{.Site}}" href="{{.SiteHref}}">{{.Site}}</a>{{range .Figs}}{{if .Href}}<a class="fig" data-label="{{.Label}}" href="{{.Href}}">{{.Count}}</a>{{else}}<span class="fig zero" data-label="{{.Label}}">{{.Count}}</span>{{end}}{{end}}<span class="c-state{{if .StateGood}} ok{{end}}{{if .StateBad}} bad{{end}}"{{if .State}} aria-label="{{if .StateGood}}healthy: {{end}}{{.State}}"{{end}}>{{.State}}</span></div>{{end}}</div>{{else}}<p class="empty">No sites yet — bookmarked series will appear here once the userscript records a chapter.</p>{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
often a later Poll confirmed or contradicted what that Reader's browser
|
||||
reported; enough contradictions stop their reports deferring a Poll, and
|
||||
clearing the marks gives that back.</p>
|
||||
{{if .Readers}}
|
||||
<ul class="readerlist">
|
||||
{{range .Readers}}
|
||||
<li>
|
||||
<li id="reader-{{.ID}}">
|
||||
<span class="reader-id">{{.DiscordID}}</span>
|
||||
<span class="reader-sessions">{{.Sessions}} session{{if ne .Sessions 1}}s{{end}}</span>
|
||||
<span class="reader-sightings">{{.Agreements}} confirmed / {{.Disagreements}} contradicted</span>
|
||||
@@ -23,26 +24,42 @@
|
||||
numbers and a threshold. */}}
|
||||
{{if .Blocked}}<span class="reader-blocked">deferral blocked</span>{{end}}
|
||||
<span class="reader-actions">
|
||||
{{/* Clearing restores a privilege, so it is a plain ghost button —
|
||||
the destruction accent belongs to revocation alone. It is offered
|
||||
on every row, including one reading zero: the remedy must be
|
||||
findable before the counters climb, not after. */}}
|
||||
<form hx-post="/readers/{{.ID}}/clear-marks" hx-target="#readers" hx-swap="outerHTML"
|
||||
hx-confirm="Clearing wipes this Reader's whole Sighting record, confirmations included. Clear?">
|
||||
<button type="submit" class="ghost">Clear marks</button>
|
||||
</form>
|
||||
{{/* The owner's own row never offers Revoke: it is the one row where the
|
||||
button would sign the tapping browser out, and the endpoint refuses
|
||||
it anyway. Logout is the deliberate way to do that. */}}
|
||||
{{/* Clearing restores a privilege, so its confirm wears the calm wash,
|
||||
not destruction. Revocation destroys sessions, so it wears danger.
|
||||
Both are confirm-gated — the opener never posts. */}}
|
||||
<button type="button" class="ghost" aria-expanded="false" aria-controls="confirm-clear-{{.ID}}"
|
||||
onclick="document.getElementById('confirm-clear-{{.ID}}').hidden = false; this.setAttribute('aria-expanded','true')">Clear marks</button>
|
||||
{{if and .Sessions (ne .ID $.OwnerID)}}
|
||||
<form hx-post="/readers/{{.ID}}/revoke" hx-target="#readers" hx-swap="outerHTML"
|
||||
hx-confirm="Revoking signs this Reader out on every device immediately. Revoke?">
|
||||
<button type="submit" class="ghost danger">Revoke sessions</button>
|
||||
</form>
|
||||
<button type="button" class="ghost danger" aria-expanded="false" aria-controls="confirm-revoke-{{.ID}}"
|
||||
onclick="document.getElementById('confirm-revoke-{{.ID}}').hidden = false; this.setAttribute('aria-expanded','true')">Revoke sessions</button>
|
||||
{{end}}
|
||||
</span>
|
||||
<div class="confirm-row calm" id="confirm-clear-{{.ID}}" role="group" aria-live="polite" hidden>
|
||||
<span>Clear this Reader's Sighting record?</span>
|
||||
<div>
|
||||
<button type="button" class="go"
|
||||
hx-post="/readers/{{.ID}}/clear-marks" hx-target="#readers" hx-swap="outerHTML"
|
||||
hx-indicator="#readers" hx-disabled-elt="this">Clear</button>
|
||||
<button type="button" onclick="document.getElementById('confirm-clear-{{.ID}}').hidden = true; var b=document.querySelector('[aria-controls=\'confirm-clear-{{.ID}}\']'); if(b){b.setAttribute('aria-expanded','false'); b.focus();}">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{{if and .Sessions (ne .ID $.OwnerID)}}
|
||||
<div class="confirm-row" id="confirm-revoke-{{.ID}}" role="group" aria-live="polite" hidden>
|
||||
<span>Revoke this Reader's sessions?</span>
|
||||
<div>
|
||||
<button type="button" class="danger-solid"
|
||||
hx-post="/readers/{{.ID}}/revoke" hx-target="#readers" hx-swap="outerHTML"
|
||||
hx-indicator="#readers" hx-disabled-elt="this">Revoke</button>
|
||||
<button type="button" onclick="document.getElementById('confirm-revoke-{{.ID}}').hidden = true; var b=document.querySelector('[aria-controls=\'confirm-revoke-{{.ID}}\']'); if(b){b.setAttribute('aria-expanded','false'); b.focus();}">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="empty">No readers yet — the roster appears after the first Discord sign-in.</p>
|
||||
{{end}}
|
||||
<p class="error-inline" role="status" hidden></p>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
@@ -9,50 +9,77 @@
|
||||
<h1 class="detail-title">{{.Title}}</h1>
|
||||
<p class="detail-key">{{.Key}} · {{.Site}} · {{.Kind}}</p>
|
||||
{{if .Cover}}<div class="cover"><img src="{{.Cover}}" alt="" loading="lazy"></div>
|
||||
{{else}}<div class="cover"></div>{{end}}
|
||||
{{else}}<div class="cover" aria-hidden="true"><span>no cover</span></div>{{end}}
|
||||
{{template "series-detail-meta" .}}
|
||||
<div class="detail-grid">
|
||||
<form class="dform" hx-post="/admin/series/{{.Key}}/latest" hx-target="#detail-meta" hx-swap="outerHTML">
|
||||
<form class="dform" hx-post="/admin/series/{{.Key}}/latest" hx-target="#detail-meta" hx-swap="outerHTML" hx-indicator="closest .dform" hx-disabled-elt="find button, input">
|
||||
<h3>Correct latest chapter</h3>
|
||||
<p class="hint">The next successful Poll overwrites this value.</p>
|
||||
<div class="field">
|
||||
<input type="number" name="chapter" step="any" placeholder="{{.Chapter}}" required>
|
||||
<button type="submit" class="ghost">Set</button>
|
||||
<label class="sr-only" for="chapter-{{.Key}}">Latest chapter number</label>
|
||||
<input id="chapter-{{.Key}}" type="number" name="chapter" step="any" placeholder="{{.Chapter}}" required aria-describedby="hint-latest-{{.Key}}">
|
||||
<button type="submit" class="ghost">Save chapter</button>
|
||||
</div>
|
||||
<p class="hint" id="hint-latest-{{.Key}}">The next successful Poll overwrites this value. Use this only when the Poll is failing or the page is wrong.</p>
|
||||
{{if .Unverified}}<p class="hint">{{.Unverified}}</p>{{end}}
|
||||
<p class="error-inline" role="status" hidden></p>
|
||||
</form>
|
||||
<form class="dform" hx-post="/admin/series/{{.Key}}/series-url" hx-target="#detail-meta" hx-swap="outerHTML">
|
||||
<form class="dform" hx-post="/admin/series/{{.Key}}/series-url" hx-target="#detail-meta" hx-swap="outerHTML" hx-indicator="closest .dform" hx-disabled-elt="find button, input">
|
||||
<h3>Repair series URL</h3>
|
||||
<p class="hint">The Poll fetches this address — storing is not verifying it. A Site-wide host change is a SQL migration, not two hundred forms.</p>
|
||||
<div class="field">
|
||||
<input type="url" name="series_url" value="{{.URL}}" required>
|
||||
<button type="submit" class="ghost">Set</button>
|
||||
<label class="sr-only" for="url-{{.Key}}">Series page URL</label>
|
||||
<input id="url-{{.Key}}" type="url" name="series_url" value="{{.URL}}" required aria-describedby="hint-url-{{.Key}}">
|
||||
<button type="submit" class="ghost">Save URL</button>
|
||||
</div>
|
||||
<p class="hint" id="hint-url-{{.Key}}">The Poll fetches this address. Saving does not verify it — A Site-wide host change is a SQL migration, not per-series edits.</p>
|
||||
<p class="error-inline" role="status" hidden></p>
|
||||
</form>
|
||||
</div>
|
||||
{{if .CanPoll}}
|
||||
<div class="dform">
|
||||
<div class="field"><a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="#detail-meta" hx-swap="outerHTML" href="#">Check now</a></div>
|
||||
<div class="field"><button type="button" class="ghost"
|
||||
hx-post="/admin/series/{{.Key}}/poll" hx-target="#detail-meta" hx-swap="outerHTML"
|
||||
hx-indicator="#detail-meta" hx-disabled-elt="this">Check now</button></div>
|
||||
<p class="error-inline" role="status" hidden></p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .CanRemove}}
|
||||
<div class="dform">
|
||||
<div class="field"><button class="ghost danger" hx-post="/admin/series/{{.Key}}/remove" hx-target="#detail-meta" hx-confirm="Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row.">Remove</button></div>
|
||||
<div class="field"><button type="button" class="ghost danger"
|
||||
aria-expanded="false" aria-controls="confirm-remove"
|
||||
onclick="document.getElementById('confirm-remove').hidden = false; this.setAttribute('aria-expanded','true')">Remove</button></div>
|
||||
<div class="confirm-row" id="confirm-remove" role="group" aria-live="polite" hidden>
|
||||
<span>Remove “{{.Title}}”? Stored cover is lost.</span>
|
||||
<div>
|
||||
<button type="button" class="danger-solid"
|
||||
hx-post="/admin/series/{{.Key}}/remove" hx-target="#detail-meta"
|
||||
hx-indicator="#detail-meta" hx-disabled-elt="this">Remove</button>
|
||||
<button type="button" onclick="document.getElementById('confirm-remove').hidden = true; document.querySelector('.dform .danger').setAttribute('aria-expanded','false')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="error-inline" role="status" hidden></p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Finished}}
|
||||
<div class="dform">
|
||||
<div class="field"><button type="button" class="ghost" hx-post="/admin/series/{{.Key}}/unfinish" hx-target="#detail-meta" hx-swap="outerHTML">Un-finish</button></div>
|
||||
<div class="field"><button type="button" class="ghost"
|
||||
hx-post="/admin/series/{{.Key}}/unfinish" hx-target="#detail-meta" hx-swap="outerHTML"
|
||||
hx-indicator="#detail-meta" hx-disabled-elt="this">Un-finish</button></div>
|
||||
<p class="error-inline" role="status" hidden></p>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="dform">
|
||||
<div class="field"><button type="button" class="ghost" onclick="document.getElementById('confirm-finish').hidden = false">Finish</button></div>
|
||||
<div class="field"><button type="button" class="ghost" aria-expanded="false" aria-controls="confirm-finish"
|
||||
onclick="document.getElementById('confirm-finish').hidden = false; this.setAttribute('aria-expanded','true')">Finish</button></div>
|
||||
{{if .SiteCompleted}}<p class="hint">{{.SiteCompleted}}</p>{{end}}
|
||||
<div class="confirm-row calm" id="confirm-finish" role="group" aria-live="polite" hidden>
|
||||
<span>Mark this Series finished?</span>
|
||||
<div>
|
||||
<button type="button" class="go" hx-post="/admin/series/{{.Key}}/finish" hx-target="#detail-meta" hx-swap="outerHTML">Finish</button>
|
||||
<button type="button" onclick="document.getElementById('confirm-finish').hidden = true">Cancel</button>
|
||||
<button type="button" class="go" hx-post="/admin/series/{{.Key}}/finish" hx-target="#detail-meta" hx-swap="outerHTML"
|
||||
hx-indicator="#detail-meta" hx-disabled-elt="this">Finish</button>
|
||||
<button type="button" onclick="document.getElementById('confirm-finish').hidden = true; document.querySelector('[aria-controls=confirm-finish]').setAttribute('aria-expanded','false')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="error-inline" role="status" hidden></p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -63,11 +90,11 @@
|
||||
the provenance line beside the number — describe the value they sit
|
||||
next to. */}}
|
||||
{{define "series-detail-meta"}}
|
||||
<div class="detail-meta" id="detail-meta">
|
||||
<div class="detail-meta" id="detail-meta" aria-live="polite">
|
||||
<span>ch {{.Chapter}}</span>
|
||||
{{if .Provenance}}<span>{{.Provenance}}</span>{{end}}
|
||||
<span>checked {{.Checked}}</span>
|
||||
<span>{{.Readers}} readers</span>
|
||||
<span>{{.Readers}} reader{{if ne .Readers 1}}s{{end}}</span>
|
||||
{{if .Corrected}}<span class="mark">{{.Corrected}}</span>{{end}}
|
||||
{{if .Pending}}<span class="mark">{{.Requested}}</span>{{end}}
|
||||
{{if .FinishedSince}}<span class="mark">{{.FinishedSince}}</span>{{end}}
|
||||
@@ -76,4 +103,4 @@
|
||||
{{if .Orphan}}<span class="mark">orphan</span>{{end}}
|
||||
{{if .SightingRaised}}<span class="mark">sighting-raised</span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
{{define "series-list"}}
|
||||
<form class="filterbar" id="filterbar" method="get" action="/admin/series">
|
||||
<input type="hidden" name="kind" value="{{.Kind}}">
|
||||
<label class="fsel"><span>Show</span><select name="filter" onchange="this.form.submit()">
|
||||
<label class="fsel"><span>Filter</span><select name="filter" onchange="this.form.submit()" aria-label="Filter series — changes apply immediately">
|
||||
{{range .Filters}}<option value="{{.Name}}"{{if .Selected}} selected{{end}}>{{.Label}} ({{.Count}})</option>{{end}}
|
||||
</select></label>
|
||||
<label class="fsel"><span>Site</span><select name="site" onchange="this.form.submit()">
|
||||
<label class="fsel"><span>Site</span><select name="site" onchange="this.form.submit()" aria-label="Filter by site — changes apply immediately">
|
||||
<option value=""{{if not .Site}} selected{{end}}>All sites</option>
|
||||
{{range .Sites}}<option value="{{.}}"{{if eq $.Site .}} selected{{end}}>{{.}}</option>{{end}}
|
||||
</select></label>
|
||||
<noscript><button type="submit" class="ghost">Apply</button></noscript>
|
||||
<span class="segrow">
|
||||
<a href="{{.KindBoth}}"{{if not .Kind}} class="active"{{end}}>both</a>
|
||||
<a href="{{.KindManga}}"{{if eq .Kind "manga"}} class="active"{{end}}>manga</a>
|
||||
@@ -41,15 +42,32 @@
|
||||
no page to fetch and on an orphan, so the owner is never offered a button
|
||||
that can never do anything. */}}
|
||||
{{define "series-row"}}
|
||||
<div class="trow{{if .Attention}} attention{{end}}{{if .Band}} band{{end}}">
|
||||
<div class="trow{{if .Attention}} attention{{end}}{{if .Band}} band{{end}}" id="row-{{.Key}}">
|
||||
<span class="c-title"><a href="/admin/series/{{.Key}}">{{.Title}}</a>{{if .Pending}}<span class="mark">{{.Requested}}</span>{{end}}</span>
|
||||
<span class="c-site site-{{.Site}}">{{.Site}}</span>
|
||||
<span class="c-ch">{{.Ch}}</span>
|
||||
<span>{{.Age}}</span>
|
||||
<span class="c-rd">{{.Readers}}</span>
|
||||
<span class="c-note">{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}{{if .Finished}}<span class="mark mark-faint">finished</span>{{end}}</span>
|
||||
<span class="c-act">{{if .CanPoll}}<a class="ghost act" hx-post="/admin/series/{{.Key}}/poll" hx-target="closest .trow" hx-swap="outerHTML" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' href="#">Check now</a>{{end}}{{if .CanRemove}}<button class="ghost danger" hx-post="/admin/series/{{.Key}}/remove" hx-target="closest .trow" hx-swap="outerHTML" hx-include="#filterbar" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}' hx-confirm="Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row.">Remove</button>{{end}}</span>
|
||||
{{/* data-label is the column word for the phone layout, which drops the
|
||||
thead: "1200.25 3d 4" says nothing without it. */}}
|
||||
<span class="c-ch" data-label="ch">{{.Ch}}</span>
|
||||
<span data-label="checked">{{.Age}}</span>
|
||||
<span class="c-rd" data-label="readers">{{.Readers}}</span>
|
||||
<span class="c-note">{{if .Failure}}<span class="mark">{{.Failure}}</span>{{end}}{{range .Notes}}<span class="mark">{{.}}</span>{{end}}{{if .More}}<span class="mark mark-faint">+{{.More}}</span>{{end}}{{if .Finished}}<span class="mark mark-faint">finished</span>{{end}}</span>
|
||||
<span class="c-act">{{if .CanPoll}}<button type="button" class="ghost"
|
||||
hx-post="/admin/series/{{.Key}}/poll" hx-target="closest .trow" hx-swap="outerHTML"
|
||||
hx-indicator="closest .trow" hx-disabled-elt="this"
|
||||
hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}'>Check now</button>{{end}}{{if .CanRemove}}<button type="button" class="ghost danger"
|
||||
aria-expanded="false" aria-controls="confirm-remove-{{.Key}}"
|
||||
onclick="document.getElementById('confirm-remove-{{.Key}}').hidden = false; this.setAttribute('aria-expanded','true')">Remove</button>{{end}}</span>
|
||||
{{if .RemovalRefused}}<span class="row-msg">a Reader has bookmarked this Series again</span>{{end}}
|
||||
{{if .CanRemove}}<div class="confirm-row" id="confirm-remove-{{.Key}}" role="group" aria-live="polite" hidden>
|
||||
<span>Remove “{{.Title}}”? Progress is lost.</span>
|
||||
<div>
|
||||
<button type="button" class="danger-solid"
|
||||
hx-post="/admin/series/{{.Key}}/remove" hx-target="closest .trow" hx-swap="outerHTML"
|
||||
hx-include="#filterbar" hx-vals='{"band":{{if .Band}}1{{else}}0{{end}}}'
|
||||
hx-indicator="closest .trow" hx-disabled-elt="this">Remove</button>
|
||||
<button type="button" onclick="document.getElementById('confirm-remove-{{.Key}}').hidden = true; document.querySelector('#row-{{.Key}} .c-act .danger').setAttribute('aria-expanded','false')">Cancel</button>
|
||||
</div>
|
||||
</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
|
||||
+60
-13
@@ -3,6 +3,7 @@ package web
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
@@ -29,6 +30,14 @@ var staticFS embed.FS
|
||||
// RecentCount is how many series the "Continue reading" strip shows.
|
||||
const RecentCount = 5
|
||||
|
||||
// maxChapterNum bounds a chapter number a Reader or the owner types. The
|
||||
// number reaches the store as a float64, so without a ceiling a hand-rolled
|
||||
// POST stores 1e308 and every later reader of that row — the poller's
|
||||
// HasNewChapter comparison, the display string — inherits it. Matches the
|
||||
// `max` on the card's chapter input; no real series is within three orders of
|
||||
// magnitude of it.
|
||||
const maxChapterNum = 9999
|
||||
|
||||
// Handler serves the browser UI: full pages at / and htmx fragments at /ui/.
|
||||
// It is a separate handler from api.Handler because the two speak different
|
||||
// representations (HTML versus JSON) to different clients under different auth.
|
||||
@@ -109,7 +118,8 @@ func (v listView) ListURL(tab string) string {
|
||||
|
||||
// loginView is what the login template receives.
|
||||
type loginView struct {
|
||||
Error string
|
||||
Error string
|
||||
GuildName string
|
||||
}
|
||||
|
||||
// New parses every template up front so a broken one kills the process at
|
||||
@@ -243,7 +253,7 @@ func (h *Handler) render(w http.ResponseWriter, status int, name string, data an
|
||||
func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
|
||||
readerID, ok := h.sessionReader(r)
|
||||
if !ok {
|
||||
h.render(w, http.StatusOK, "login", loginView{})
|
||||
h.render(w, http.StatusOK, "login", loginView{GuildName: h.discord.GuildName})
|
||||
return
|
||||
}
|
||||
view, err := h.buildListView(readerID, libOf(r.URL.Query().Get("lib")), r.URL.Query().Get("tab"))
|
||||
@@ -418,12 +428,20 @@ func (h *Handler) refreshChrome(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
h.writeChromeOOB(w, view)
|
||||
}
|
||||
// writeAnnounceOOB appends a visually-hidden live region update out-of-band,
|
||||
// so a successful mutation announces itself without moving focus.
|
||||
func (h *Handler) writeAnnounceOOB(w http.ResponseWriter, msg string) {
|
||||
fmt.Fprintf(w, `<div id="sr-announce" class="sr-only" role="status" aria-live="polite" aria-atomic="true" hx-swap-oob="true">%s</div>`, template.HTMLEscapeString(msg))
|
||||
}
|
||||
func (h *Handler) writeNoticeOOB(w http.ResponseWriter, msg string) {
|
||||
fmt.Fprintf(w, `<p id="notice" class="notice" hx-swap-oob="true">%s</p>`, template.HTMLEscapeString(msg))
|
||||
}
|
||||
|
||||
// renderLogin renders the login page with an error message, for refused or
|
||||
// failed sign-ins. Every message is author-written text — nothing Discord
|
||||
// supplied is ever interpolated into a page.
|
||||
func (h *Handler) renderLogin(w http.ResponseWriter, status int, msg string) {
|
||||
h.render(w, status, "login", loginView{Error: msg})
|
||||
h.render(w, status, "login", loginView{Error: msg, GuildName: h.discord.GuildName})
|
||||
}
|
||||
|
||||
// logout revokes the session row and clears the cookie in one step: the next
|
||||
@@ -488,7 +506,14 @@ func (h *Handler) uiFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
b.Favorite = !b.Favorite
|
||||
b.UpdatedAt = time.Now().UnixMilli()
|
||||
msg := ""
|
||||
if b.Favorite {
|
||||
msg = fmt.Sprintf("Added %s to favourites", b.Title)
|
||||
} else {
|
||||
msg = fmt.Sprintf("Removed %s from favourites", b.Title)
|
||||
}
|
||||
h.saveAndRenderCard(w, r, b)
|
||||
h.writeAnnounceOOB(w, msg)
|
||||
}
|
||||
|
||||
// uiStatus moves a bookmark between the two lifecycle buckets. Finished is not
|
||||
@@ -514,7 +539,14 @@ func (h *Handler) uiStatus(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
b.UpdatedAt = time.Now().UnixMilli()
|
||||
msg := ""
|
||||
if b.Status == store.StatusArchived {
|
||||
msg = fmt.Sprintf("Archived %s", b.Title)
|
||||
} else {
|
||||
msg = fmt.Sprintf("Restored %s", b.Title)
|
||||
}
|
||||
h.saveAndRenderCard(w, r, b)
|
||||
h.writeAnnounceOOB(w, msg)
|
||||
}
|
||||
|
||||
// uiChapter forces the read chapter to a value the user typed.
|
||||
@@ -539,8 +571,8 @@ func (h *Handler) uiChapter(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
raw := strings.TrimSpace(r.PostFormValue("chapter"))
|
||||
num, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil || num < 0 || math.IsNaN(num) || math.IsInf(num, 0) {
|
||||
http.Error(w, "chapter must be a non-negative number", http.StatusBadRequest)
|
||||
if err != nil || num < 0 || num > maxChapterNum || math.IsNaN(num) || math.IsInf(num, 0) {
|
||||
http.Error(w, "chapter must be a non-negative number below 10000", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -550,27 +582,42 @@ func (h *Handler) uiChapter(w http.ResponseWriter, r *http.Request) {
|
||||
b.LastChapterNum = num
|
||||
}
|
||||
b.UpdatedAt = time.Now().UnixMilli()
|
||||
msg := fmt.Sprintf("Updated %s to chapter %s", b.Title, raw)
|
||||
h.saveAndRenderCard(w, r, b)
|
||||
h.writeAnnounceOOB(w, msg)
|
||||
}
|
||||
|
||||
// uiDelete removes the row and answers with an empty body, which htmx swaps in
|
||||
// place of the card — removing it from the page.
|
||||
func (h *Handler) uiDelete(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.PathValue("key")
|
||||
if key == "" {
|
||||
http.Error(w, "missing key", http.StatusBadRequest)
|
||||
b, ok := h.loadForMutation(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.store.Delete(readerOf(r), key); err != nil {
|
||||
log.Printf("ui delete %q: %v", key, err)
|
||||
if err := h.store.Delete(readerOf(r), b.Key); err != nil {
|
||||
log.Printf("ui delete %q: %v", b.Key, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
// The empty body is what removes the card; the chrome still has to be told
|
||||
// the library got smaller.
|
||||
h.refreshChrome(w, r)
|
||||
msg := fmt.Sprintf("Removed %s", b.Title)
|
||||
h.writeAnnounceOOB(w, msg)
|
||||
h.writeNoticeOOB(w, msg)
|
||||
view, err := h.buildListView(readerOf(r), currentLib(r), currentTab(r))
|
||||
if err != nil {
|
||||
log.Printf("ui chrome: %v", err)
|
||||
return
|
||||
}
|
||||
h.writeChromeOOB(w, view)
|
||||
if len(view.Items) == 0 {
|
||||
fmt.Fprint(w, `<main id="list" class="list" tabindex="-1" hx-swap-oob="true">`)
|
||||
if err := h.tmpl.ExecuteTemplate(w, "list", view); err != nil {
|
||||
log.Printf("render list oob: %v", err)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `</main>`)
|
||||
}
|
||||
}
|
||||
|
||||
// installUserscript renders the bindmounted script with the acting Reader's
|
||||
|
||||
+29
-4
@@ -14,6 +14,7 @@ import (
|
||||
"bookmarkmanager/backend/internal/api"
|
||||
"bookmarkmanager/backend/internal/httpmw"
|
||||
"bookmarkmanager/backend/internal/latest"
|
||||
"bookmarkmanager/backend/internal/notify"
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
"bookmarkmanager/backend/internal/token"
|
||||
"bookmarkmanager/backend/internal/userscript"
|
||||
@@ -59,6 +60,12 @@ type Config struct {
|
||||
// the Lanes page reports the fact and derives reachability from the pass
|
||||
// log rather than asking the poller (issue #145).
|
||||
BrowserWSURL string
|
||||
// DiscordWebhookURL is the webhook owner notices post to (issue #171).
|
||||
// Unset means the whole path is off — a local stack needs no webhook,
|
||||
// exactly as the browser URL behaves. The address is a secret in the
|
||||
// class of TOKEN_KEY: never logged, and it must not reach any line that
|
||||
// prints configuration.
|
||||
DiscordWebhookURL string
|
||||
// LatestPoll configures the background latest-chapter fetcher.
|
||||
LatestPoll LatestPoll
|
||||
}
|
||||
@@ -114,12 +121,14 @@ func loadConfig() Config {
|
||||
UserscriptPath: envOr("USERSCRIPT_PATH", "/userscript/manga-bookmark.user.js"),
|
||||
NovelUserscriptPath: envOr("NOVEL_USERSCRIPT_PATH", "/userscript/novel-bookmark.user.js"),
|
||||
BrowserWSURL: os.Getenv("BROWSER_WS_URL"),
|
||||
DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"),
|
||||
LatestPoll: loadLatestPoll(),
|
||||
}
|
||||
c.Discord = web.DiscordConfig{
|
||||
ClientID: os.Getenv("DISCORD_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("DISCORD_CLIENT_SECRET"),
|
||||
GuildID: os.Getenv("DISCORD_GUILD_ID"),
|
||||
GuildName: os.Getenv("DISCORD_GUILD_NAME"),
|
||||
RequiredRole: os.Getenv("DISCORD_REQUIRED_ROLE"),
|
||||
APIBase: envOr("DISCORD_API_BASE", "https://discord.com/api/v10"),
|
||||
RedirectURI: os.Getenv("DISCORD_REDIRECT_URI"),
|
||||
@@ -288,10 +297,22 @@ func main() {
|
||||
}
|
||||
s.OnSeriesCreated = acq.Acquire
|
||||
}
|
||||
// Owner notices (issue #171): a configured webhook makes the poller tell
|
||||
// the owner about stalled Lanes. Unset means the whole path is off — a
|
||||
// local stack needs no webhook, exactly as the browser URL behaves. Only
|
||||
// the presence is logged; the address itself is a secret in the class of
|
||||
// TOKEN_KEY.
|
||||
var notifier latest.Notifier
|
||||
if u := strings.TrimSpace(cfg.DiscordWebhookURL); u != "" {
|
||||
notifier = notify.New(u, cfg.PublicBaseURL)
|
||||
log.Println("owner notices: enabled")
|
||||
} else {
|
||||
log.Println("owner notices: disabled (DISCORD_WEBHOOK_URL unset)")
|
||||
}
|
||||
// The poller's only connection to the web layer is the database now: it is
|
||||
// started for its own sake, and the Lanes page reads the pass rows it
|
||||
// records (issue #145).
|
||||
startLatestPoller(pollCtx, s, cfg.LatestPoll, browser)
|
||||
startLatestPoller(pollCtx, s, cfg.LatestPoll, browser, notifier)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
@@ -324,7 +345,9 @@ func main() {
|
||||
|
||||
// newLatestPoller wires the fetcher seams into the poller. Pace is registry
|
||||
// property, not config (issue #100), so there are no knobs to pass through.
|
||||
func newLatestPoller(s *store.Store, cfg LatestPoll, fetch, browser latest.Fetcher) *latest.Poller {
|
||||
// notifier is nil when no webhook is configured: a missing webhook is a
|
||||
// silent off switch, not an error (issue #171).
|
||||
func newLatestPoller(s *store.Store, cfg LatestPoll, fetch, browser latest.Fetcher, notifier latest.Notifier) *latest.Poller {
|
||||
var covers latest.BrowserCoverFetcher
|
||||
if f, ok := browser.(latest.BrowserCoverFetcher); ok {
|
||||
covers = f
|
||||
@@ -335,6 +358,7 @@ func newLatestPoller(s *store.Store, cfg LatestPoll, fetch, browser latest.Fetch
|
||||
BrowserFetch: browser,
|
||||
CoverFetch: covers,
|
||||
CoverBytesFetch: latest.NewCoverFetcher(),
|
||||
Notify: notifier,
|
||||
Now: time.Now,
|
||||
}
|
||||
}
|
||||
@@ -345,7 +369,8 @@ func newLatestPoller(s *store.Store, cfg LatestPoll, fetch, browser latest.Fetch
|
||||
// tracking, which is exactly how it behaved before. It returns the running
|
||||
// Poller, or nil when there is none; the caller starts it for its own sake —
|
||||
// the Lanes page reads the pass log, so no return value is wired anywhere.
|
||||
func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, browser latest.Fetcher) *latest.Poller {
|
||||
// notifier is nil when DISCORD_WEBHOOK_URL is unset (issue #171).
|
||||
func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, browser latest.Fetcher, notifier latest.Notifier) *latest.Poller {
|
||||
if !cfg.Enabled {
|
||||
log.Println("latest-chapter poller: disabled by config")
|
||||
return nil
|
||||
@@ -358,7 +383,7 @@ func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, brow
|
||||
// Nil browser: sites behind a JavaScript challenge are simply not polled,
|
||||
// and their latest_chapter comes from the userscript alone — which is how
|
||||
// the service behaved before the sidecar existed.
|
||||
p := newLatestPoller(s, cfg, f, browser)
|
||||
p := newLatestPoller(s, cfg, f, browser, notifier)
|
||||
|
||||
go p.Run(ctx)
|
||||
return p
|
||||
|
||||
+23
-1
@@ -28,6 +28,20 @@ func TestLoadConfigReadsCoverDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigReadsDiscordWebhook(t *testing.T) {
|
||||
// The address is read, never defaulted: unset stays empty (the whole
|
||||
// path is off), set flows into the Config for the poller's notifier.
|
||||
const url = "https://discord.com/api/webhooks/000000/secret"
|
||||
t.Setenv("DISCORD_WEBHOOK_URL", url)
|
||||
if got := loadConfig().DiscordWebhookURL; got != url {
|
||||
t.Fatalf("DiscordWebhookURL = %q, want %q", got, url)
|
||||
}
|
||||
t.Setenv("DISCORD_WEBHOOK_URL", "")
|
||||
if got := loadConfig().DiscordWebhookURL; got != "" {
|
||||
t.Fatalf("DiscordWebhookURL = %q, want empty when unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLatestPollEnabledParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
raw string
|
||||
@@ -51,7 +65,8 @@ func TestLoadLatestPollEnabledParsing(t *testing.T) {
|
||||
// nothing here sizes a cooldown any more.
|
||||
func TestNewLatestPollerWiresFetchers(t *testing.T) {
|
||||
tls := &latest.TLSFetcher{}
|
||||
p := newLatestPoller(nil, LatestPoll{Enabled: true}, tls, nil)
|
||||
notifier := &stubNotifier{}
|
||||
p := newLatestPoller(nil, LatestPoll{Enabled: true}, tls, nil, notifier)
|
||||
if p.Fetch != tls {
|
||||
t.Fatalf("Fetch not wired")
|
||||
}
|
||||
@@ -67,8 +82,15 @@ func TestNewLatestPollerWiresFetchers(t *testing.T) {
|
||||
if p.Now == nil {
|
||||
t.Fatalf("Now = nil, want the live clock")
|
||||
}
|
||||
if p.Notify != notifier {
|
||||
t.Fatalf("Notify = %v, want the configured notifier", p.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
// stubNotifier satisfies latest.Notifier so newLatestPoller's wiring can be
|
||||
// asserted; it is never called.
|
||||
type stubNotifier struct{ latest.Notifier }
|
||||
|
||||
func TestPutStatusValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
+538
-39
@@ -1,11 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -918,7 +920,7 @@ func TestLanesRenderFromSeededRowsAfterRestart(t *testing.T) {
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.Add(-time.Minute).UnixMilli(), Due: 5, Checked: 5, GapMS: 20_000})
|
||||
|
||||
body := lanesBody(t, router, st)
|
||||
if !strings.Contains(body, `class="c-site">asura</span>`) {
|
||||
if !strings.Contains(body, `<a class="c-site" href="/admin/series?filter=failing&site=asura">asura</a>`) {
|
||||
t.Fatalf("asura row missing from a restart-read database:\n%s", body)
|
||||
}
|
||||
// The fragment carries its own single timer and answers the swap it asked
|
||||
@@ -953,22 +955,32 @@ func TestSkipReasonIsNotAStall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The five outcome counts render named — the page never prints the word
|
||||
// "failures" — and a Site with none observed says so rather than drawing a
|
||||
// blank cell.
|
||||
// The six outcome counts render named — the page never prints the word
|
||||
// "failures" — in the taxonomy's fixed order, not found beside the word it
|
||||
// split out of. A Site with none observed says so rather than drawing a
|
||||
// blank cell, and a zero not_found renders no chip of its own.
|
||||
func TestNamedOutcomeChips(t *testing.T) {
|
||||
router, st := newWebTestServer(t, lanesConfig())
|
||||
now := time.Now()
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Refused: 5, NoChapter: 2, Errors: 1})
|
||||
seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli(), Unreachable: 3, Unfetchable: 4})
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Refused: 5, Unreachable: 3, NoChapter: 2, Unfetchable: 4, NotFound: 6, Errors: 1})
|
||||
seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli(), Unreachable: 7, Unfetchable: 8})
|
||||
seedPass(t, st, store.LanePass{Site: "comix", RanAt: now.UnixMilli()})
|
||||
|
||||
body := lanesBody(t, router, st)
|
||||
for _, want := range []string{"refused 5", "no chapter 2", "errors 1", "unreachable 3", "unfetchable 4"} {
|
||||
for _, want := range []string{"refused 5", "unreachable 3", "no chapter 2", "unfetchable 4", "not found 6", "errors 1"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("lane chips lack %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
wantSeq := `<span class="mark">refused 5</span><span class="mark-faint"> · </span><span class="mark">unreachable 3</span><span class="mark-faint"> · </span><span class="mark">no chapter 2</span><span class="mark-faint"> · </span><span class="mark">unfetchable 4</span><span class="mark-faint"> · </span><span class="mark">not found 6</span><span class="mark-faint"> · </span><span class="mark">errors 1</span>`
|
||||
if !strings.Contains(body, wantSeq) {
|
||||
t.Errorf("chips do not render in the fixed order (… unfetchable, not found, errors …):\n%s", body)
|
||||
}
|
||||
// demonic has outcomes but no not_found: exactly one "not found" in the
|
||||
// whole fragment — asura's — so a zero count renders none.
|
||||
if got := strings.Count(body, "not found"); got != 1 {
|
||||
t.Errorf("the word \"not found\" appears %d times, want exactly the one seeded chip", got)
|
||||
}
|
||||
if strings.Contains(body, "failures") {
|
||||
t.Errorf("the page prints the forbidden word \"failures\":\n%s", body)
|
||||
}
|
||||
@@ -979,6 +991,31 @@ func TestNamedOutcomeChips(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Each Site row on the Lanes page carries one plain navigation link to that
|
||||
// Site's failing Series — a navigation, not a number: the outcome chips
|
||||
// beside it stay unlinked permanently, so the row offers one click and one
|
||||
// answer. The clean Site links too, into an empty list (issue #167).
|
||||
func TestLaneSiteLinksToFailingSeriesAndChipsStayUnlinked(t *testing.T) {
|
||||
router, st := newWebTestServer(t, lanesConfig())
|
||||
now := time.Now()
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), NotFound: 6})
|
||||
seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli()})
|
||||
|
||||
body := lanesBody(t, router, st)
|
||||
if !strings.Contains(body, `<a class="c-site" href="/admin/series?filter=failing&site=asura">asura</a>`) {
|
||||
t.Errorf("the failing Site's name is not a link to its failing Series:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `<a class="c-site" href="/admin/series?filter=failing&site=demonic">demonic</a>`) {
|
||||
t.Errorf("the clean Site's name is not a link to its (empty) failing Series:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `<span class="mark">not found 6</span>`) {
|
||||
t.Errorf("the outcome chip no longer renders beside the link:\n%s", body)
|
||||
}
|
||||
if got := strings.Count(body, `<a class="c-site"`); got != 2 {
|
||||
t.Errorf("Site anchor count = %d, want exactly 2 (only the two Site names navigate):\n%s", got, body)
|
||||
}
|
||||
}
|
||||
|
||||
// Browser configuration is a deployment fact and reachability is derived from
|
||||
// the latest browser-Site passes inside the refusal backoff — no poller in any
|
||||
// of the three.
|
||||
@@ -1163,7 +1200,9 @@ func TestLaneResumeRoundTrip(t *testing.T) {
|
||||
if !strings.Contains(body, `hx-post="/admin/lanes/asura/pause"`) || !strings.Contains(body, ">Pause</button>") {
|
||||
t.Errorf("resume response does not offer Pause again:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "Resume") || strings.Contains(body, "paused") {
|
||||
// The page's own copy names pause as a healthy idle state, so the tell is
|
||||
// the row's phrase and the Resume control, not the bare word.
|
||||
if strings.Contains(body, ">Resume</button>") || strings.Contains(body, "resumes in") {
|
||||
t.Errorf("resume response still reads as paused:\n%s", body)
|
||||
}
|
||||
paused, _, err := st.LaneGates("asura")
|
||||
@@ -1215,7 +1254,7 @@ func TestPausedLaneRendersAsPausedNotStalled(t *testing.T) {
|
||||
}
|
||||
|
||||
body := lanesBody(t, router, st)
|
||||
if !strings.Contains(body, `class="ok">paused · resumes in 6h<`) {
|
||||
if !strings.Contains(body, `>paused · resumes in 6h<`) || !strings.Contains(body, `class="ok"`) {
|
||||
t.Errorf("a paused Lane does not render the patina phrase:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, `class="trow attention"`) {
|
||||
@@ -1615,7 +1654,9 @@ func TestChapterOverrideRejectsBadInput(t *testing.T) {
|
||||
Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1_000_000,
|
||||
})
|
||||
|
||||
for _, bad := range []string{"", "abc", "-3", "NaN", "Infinity", "-Inf"} {
|
||||
// 1e5 is past the ceiling but a perfectly good float64, so it only fails
|
||||
// if the handler bounds the value rather than just parsing it.
|
||||
for _, bad := range []string{"", "abc", "-3", "NaN", "Infinity", "-Inf", "10000", "1e5"} {
|
||||
t.Run("input "+bad, func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, uiRequest(t, st, http.MethodPost,
|
||||
@@ -2177,14 +2218,15 @@ func TestNovelNewTabFallsBackToAll(t *testing.T) {
|
||||
// cannot produce an orphan series or a Reader-raised Latest Chapter — the
|
||||
// same reason the store's admin tests seed this way.
|
||||
type seriesRowSeed struct {
|
||||
key string
|
||||
kind string
|
||||
url string
|
||||
cover string // cover_address
|
||||
checkedAt int64
|
||||
latestNum *float64
|
||||
bookmarks int // readers that hold it; 0 = orphan
|
||||
raisedBy bool // a Reader's report is attributed as the raiser
|
||||
key string
|
||||
kind string
|
||||
url string
|
||||
cover string // cover_address
|
||||
checkedAt int64
|
||||
latestNum *float64
|
||||
bookmarks int // readers that hold it; 0 = orphan
|
||||
raisedBy bool // a Reader's report is attributed as the raiser
|
||||
siteCompletedAt int64 // the Site's own marker (issue #168), 0 = not set
|
||||
}
|
||||
|
||||
// seedSeriesRow inserts one series row and its bookmarks (owner first, then
|
||||
@@ -2204,10 +2246,10 @@ func seedSeriesRow(t *testing.T, st *store.Store, db *sql.DB, seed seriesRowSeed
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO series (site, series_id, title, kind, series_url, cover_address,
|
||||
latest_checked_at, latest_chapter, latest_chapter_num)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
latest_checked_at, latest_chapter, latest_chapter_num, site_completed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
site, seriesID, "Title of "+seed.key, seed.kind, seed.url, seed.cover,
|
||||
seed.checkedAt, latestChapter, seed.latestNum); err != nil {
|
||||
seed.checkedAt, latestChapter, seed.latestNum, seed.siteCompletedAt); err != nil {
|
||||
t.Fatalf("seed series %q: %v", seed.key, err)
|
||||
}
|
||||
for i := range seed.bookmarks {
|
||||
@@ -2343,6 +2385,46 @@ func TestSeriesListFinishedFilterOption(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The site-completed filter is in the select's vocabulary like any other,
|
||||
// and the list it opens shows exactly the hinted Series — the Site's marker
|
||||
// with no owner's Finish, because a retired Series is not work a Poll is
|
||||
// coming to do (issue #170).
|
||||
func TestSeriesListSiteCompletedFilterOption(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
hinted := time.Now().Add(-72 * time.Hour).UnixMilli()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:hinted", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(10), bookmarks: 1, siteCompletedAt: hinted})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:plain", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:retired", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(2), bookmarks: 1, siteCompletedAt: hinted})
|
||||
if err := st.SetSeriesFinished("asura", "retired", time.Now().UnixMilli()); err != nil {
|
||||
t.Fatalf("finish asura:retired: %v", err)
|
||||
}
|
||||
srv := newRouter(st, testConfig())
|
||||
|
||||
body := adminSeriesPage(t, srv, st, "?filter=site_completed")
|
||||
if !strings.Contains(body, "Title of asura:hinted") {
|
||||
t.Errorf("site-completed list misses its row:\n%s", body)
|
||||
}
|
||||
for _, banned := range []string{"Title of asura:plain", "Title of asura:retired"} {
|
||||
if strings.Contains(body, banned) {
|
||||
t.Errorf("site-completed list renders %q:\n%s", banned, body)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, "1 series") {
|
||||
t.Errorf("site-completed heading lacks the count:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `Site says completed (1)`) {
|
||||
t.Errorf("the site-completed option lacks its count:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `<option value="site_completed" selected>`) {
|
||||
t.Errorf("the site-completed option is not selected:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// ?filter=stale&site=kagane&kind=manga narrows on all three at once: only the
|
||||
// kagane manga stale row renders, and every link the page emits carries the
|
||||
// filter and Site so the narrowing survives in the URL.
|
||||
@@ -2600,11 +2682,14 @@ func TestSeriesListRowShape(t *testing.T) {
|
||||
if got := strings.Count(body, "Check now"); got != 2 {
|
||||
t.Errorf("Check now control count = %d, want 2 (only the two pollable rows):\n%s", got, body)
|
||||
}
|
||||
if got := strings.Count(body, ">Remove<"); got != 1 {
|
||||
t.Errorf("Remove control count = %d, want 1 (only the orphan):\n%s", got, body)
|
||||
if got := strings.Count(body, ">Remove<"); got != 2 {
|
||||
t.Errorf("Remove control count = %d, want 2 (opener + confirm for the orphan):\n%s", got, body)
|
||||
}
|
||||
if strings.Contains(body, "confirm-row") {
|
||||
t.Errorf("a confirm row renders in this batch:\n%s", body)
|
||||
if !strings.Contains(body, "confirm-row") {
|
||||
t.Errorf("expected hidden confirm-row for the orphan:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `hidden>`) {
|
||||
t.Errorf("confirm-row should be hidden initially:\n%s", body)
|
||||
}
|
||||
// No ember: the new-chapter signal stays off the admin surface. Scoped to
|
||||
// the page content — the shell's brand mark legitimately wears the ember
|
||||
@@ -2619,6 +2704,44 @@ func TestSeriesListRowShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The row's fact line names the standing failure and how long it has stood,
|
||||
// through one format for every age: a one-hour-old failure and a
|
||||
// three-month-old one both render as "<word> · <checkedAge>", and a Series
|
||||
// with no failure row renders no marker at all. The marker is its own field,
|
||||
// never a Notes chip — the chips cap at two plus a tail, so the failure
|
||||
// would be the fact most likely to be truncated away (issue #167).
|
||||
func TestSeriesListFailureMarkerAges(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
now := time.Now()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:hour", url: "u", cover: "c", checkedAt: now.UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:months", url: "u", cover: "c", checkedAt: now.UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:fine", url: "u", cover: "c", checkedAt: now.UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||
if err := st.RecordSeriesFailure("asura", "hour", "not_found", now.Add(-time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("seed failure row on hour: %v", err)
|
||||
}
|
||||
if err := st.RecordSeriesFailure("asura", "months", "no_chapter", now.Add(-90*24*time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("seed failure row on months: %v", err)
|
||||
}
|
||||
|
||||
body := adminSeriesPage(t, newRouter(st, testConfig()), st, "")
|
||||
if !strings.Contains(body, `<span class="mark">not found · 1h ago</span>`) {
|
||||
t.Errorf("the one-hour failure lacks its marker:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `<span class="mark">no chapter · 90d ago</span>`) {
|
||||
t.Errorf("the three-month failure lacks its marker:\n%s", body)
|
||||
}
|
||||
// The clean row carries none, and neither word leaks anywhere else on
|
||||
// the page: exactly one marker per failing row.
|
||||
if strings.Count(body, "not found") != 1 || strings.Count(body, "no chapter") != 1 {
|
||||
t.Errorf("failure words appear more than once, or on a clean row:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The per-Series page renders every Series-level fact the admin read model
|
||||
// holds for the key the list row already shows: title, the composite key with
|
||||
// Site and kind, the Latest Chapter, the check age and the anonymous Reader
|
||||
@@ -2758,6 +2881,57 @@ func TestAdminSeriesDetailRendersMarks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The detail page adds one sentence beside the Latest Chapter correction
|
||||
// control only while the number came from a Reader and the failure row has
|
||||
// stood past the owner window: a Reader-attributed failing Series carries it,
|
||||
// a machine-read failing Series does not, and neither does a
|
||||
// Reader-attributed Series whose failure is still inside the window. The
|
||||
// sentence names no Reader (issue #167).
|
||||
func TestAdminSeriesDetailUnverifiedSentence(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
now := time.Now()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:reader-fail", url: "u", cover: "c", checkedAt: now.UnixMilli(), latestNum: floatPtr(2), bookmarks: 1, raisedBy: true})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:machine-fail", url: "u", cover: "c", checkedAt: now.UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:reader-recent", url: "u", cover: "c", checkedAt: now.UnixMilli(), latestNum: floatPtr(3), bookmarks: 1, raisedBy: true})
|
||||
if err := st.RecordSeriesFailure("asura", "reader-fail", "not_found", now.Add(-24*time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("seed failure row on reader-fail: %v", err)
|
||||
}
|
||||
if err := st.RecordSeriesFailure("asura", "machine-fail", "not_found", now.Add(-24*time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("seed failure row on machine-fail: %v", err)
|
||||
}
|
||||
if err := st.RecordSeriesFailure("asura", "reader-recent", "not_found", now.Add(-time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("seed failure row on reader-recent: %v", err)
|
||||
}
|
||||
srv := newRouter(st, testConfig())
|
||||
|
||||
want := "Unverified Reader number: the page has been failing for over 12h, so this Reader-reported chapter is unconfirmed."
|
||||
fetch := func(key string) string {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/series/"+key, nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /admin/series/%s status = %d, want 200", key, rr.Code)
|
||||
}
|
||||
return rr.Body.String()
|
||||
}
|
||||
if body := fetch("asura:reader-fail"); !strings.Contains(body, want) {
|
||||
t.Errorf("a Reader-attributed failing Series lacks the unverified sentence:\n%s", body)
|
||||
}
|
||||
if body := fetch("asura:machine-fail"); strings.Contains(body, want) {
|
||||
t.Errorf("a machine-read failing Series carries the unverified sentence:\n%s", body)
|
||||
}
|
||||
if body := fetch("asura:reader-recent"); strings.Contains(body, want) {
|
||||
t.Errorf("a Reader-attributed Series failing inside the window carries the sentence:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The provenance line beside the chapter names the actor class behind the
|
||||
// value — "machine read" for a checked Series, "correction" for the owner's
|
||||
// stamp, "sighting" for a Reader-raised one — and appears nowhere in the
|
||||
@@ -2934,7 +3108,7 @@ func TestOverviewVerdictThreeStates(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("lanes need a look", func(t *testing.T) {
|
||||
seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli(), Skip: latest.SkipRefusing})
|
||||
seedPass(t, st, store.LanePass{Site: "demonic", RanAt: now.UnixMilli(), Due: 1, Checked: 0, Skip: "", Refused: 0})
|
||||
body := overviewBody(t, router, st)
|
||||
if !strings.Contains(body, "1 lane needs a look") {
|
||||
t.Errorf("attention verdict missing:\n%s", body)
|
||||
@@ -2953,6 +3127,96 @@ func TestOverviewVerdictThreeStates(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestOverviewVerdictFromFaults mirrors the four fault inputs the notifier judges
|
||||
// (stall, no-browser-route, sidecar-down, adapter-broken); plus healthy and
|
||||
// virgin. Each seeds the durable rows the page reads, with no poller running.
|
||||
func TestOverviewVerdictFromFaults(t *testing.T) {
|
||||
t.Run("stall is a fault", func(t *testing.T) {
|
||||
router, st := newWebTestServer(t, testConfig())
|
||||
now := time.Now()
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Due: 3, Checked: 0, Skip: "", Refused: 0})
|
||||
body := overviewBody(t, router, st)
|
||||
if !strings.Contains(body, "1 lane needs a look") {
|
||||
t.Errorf("stall fault verdict missing:\n%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no-browser-route is a fault on plain TLS site", func(t *testing.T) {
|
||||
router, st := newWebTestServer(t, testConfig())
|
||||
old := time.Now().Add(-13 * time.Hour).UnixMilli()
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: old, Skip: latest.SkipRefusing})
|
||||
body := overviewBody(t, router, st)
|
||||
if !strings.Contains(body, "1 lane needs a look") {
|
||||
t.Errorf("no-browser-route fault verdict missing:\n%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sidecar-down is one fault even though every browser lane is down", func(t *testing.T) {
|
||||
router, st := newWebTestServer(t, testConfig())
|
||||
now := time.Now().UnixMilli()
|
||||
for _, site := range []string{"comix", "kagane", "novelfull"} {
|
||||
seedPass(t, st, store.LanePass{Site: site, RanAt: now, Skip: latest.SkipSidecarDown})
|
||||
}
|
||||
body := overviewBody(t, router, st)
|
||||
if !strings.Contains(body, "1 lane needs a look") {
|
||||
t.Errorf("sidecar-down fault should count as one, want %q:\n%s", "1 lane needs a look", body)
|
||||
}
|
||||
if strings.Contains(body, "3 lanes need a look") {
|
||||
t.Errorf("sidecar-down fault must not count lanes, got 3:\n%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("adapter-broken is a fault over half no_chapter", func(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
router := newRouter(st, testConfig())
|
||||
// Four series of the same site; three hold an old no_chapter row (>12h).
|
||||
now := time.Now()
|
||||
old := now.Add(-13 * time.Hour).UnixMilli()
|
||||
for i := 1; i <= 4; i++ {
|
||||
key := fmt.Sprintf("asura:a%d", i)
|
||||
url := fmt.Sprintf("https://asurascans.com/comics/a%d", i)
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: key, url: url, cover: "aaa", checkedAt: now.UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||
}
|
||||
for i := 1; i <= 3; i++ {
|
||||
if err := st.RecordSeriesFailure("asura", fmt.Sprintf("a%d", i), "no_chapter", old); err != nil {
|
||||
t.Fatalf("seed no_chapter failure: %v", err)
|
||||
}
|
||||
}
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Due: 1, Checked: 1})
|
||||
body := overviewBody(t, router, st)
|
||||
if !strings.Contains(body, "1 lane needs a look") {
|
||||
t.Errorf("adapter-broken fault verdict missing:\n%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("healthy when no fault holds", func(t *testing.T) {
|
||||
router, st := newWebTestServer(t, testConfig())
|
||||
now := time.Now()
|
||||
seedPass(t, st, store.LanePass{Site: "asura", RanAt: now.UnixMilli(), Due: 1, Checked: 1})
|
||||
seedPass(t, st, store.LanePass{Site: "kagane", RanAt: now.UnixMilli(), Skip: latest.SkipAsleep, Due: 1})
|
||||
body := overviewBody(t, router, st)
|
||||
if !strings.Contains(body, "all lanes healthy") {
|
||||
t.Errorf("healthy verdict missing:\n%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("virgin still draws no figures", func(t *testing.T) {
|
||||
virgin, fresh := newWebTestServer(t, testConfig())
|
||||
body := overviewBody(t, virgin, fresh)
|
||||
if !strings.Contains(body, "no Lane has reported yet") {
|
||||
t.Errorf("virgin verdict missing:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "all lanes healthy") || strings.Contains(body, "series waiting") {
|
||||
t.Errorf("virgin verdict draws confident zeroes:\n%s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The Overview never refreshes itself: the Lane rest is an hour, so a timer
|
||||
// would re-run a cross-Series join to redraw identical rows. Only the Lanes
|
||||
// block carries a refresh attribute.
|
||||
@@ -3027,10 +3291,11 @@ func TestOverviewZeroIsAnUnlinkedDigit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The finished figure rides last in the Hygiene block: seriesFilterOrder
|
||||
// appends it at the tail, the block renders order[1:], and the count comes
|
||||
// from the same aggregate the select is numbered from. Asserting on the body
|
||||
// order catches a render that merely shows the figure somewhere.
|
||||
// The finished figure and the site-completed hint ride last in the Hygiene
|
||||
// block: seriesFilterOrder appends the pair at the tail, the block renders
|
||||
// order[1:], and each count comes from the same aggregate the select is
|
||||
// numbered from. Asserting on the body order catches a render that merely
|
||||
// shows a figure somewhere.
|
||||
func TestOverviewFinishedFigureRidesLast(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
@@ -3055,17 +3320,69 @@ func TestOverviewFinishedFigureRidesLast(t *testing.T) {
|
||||
}
|
||||
labels = append(labels, m[1])
|
||||
}
|
||||
if len(labels) != 8 {
|
||||
t.Fatalf("hygiene block renders %d figures, want 8 (seven problems + finished):\n%s", len(labels), body)
|
||||
if len(labels) != 11 {
|
||||
t.Fatalf("hygiene block renders %d figures, want 11 (nine problems + finished + site completed):\n%s", len(labels), body)
|
||||
}
|
||||
if labels[len(labels)-1] != "Finished" {
|
||||
t.Errorf("finished figure does not ride last; hygiene order = %v:\n%s", labels, body)
|
||||
if labels[len(labels)-2] != "Finished" || labels[len(labels)-1] != "Site says completed" {
|
||||
t.Errorf("the informational tail does not ride last; hygiene order = %v:\n%s", labels, body)
|
||||
}
|
||||
if !strings.Contains(body, `href="/admin/series?filter=finished">1</a>`) {
|
||||
t.Errorf("the finished figure lacks its count and door:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The site-completed figure is a door into the list that counts it, and the
|
||||
// figure agrees with the list's own heading: both come from the same
|
||||
// aggregate, so they cannot drift apart.
|
||||
func TestOverviewSiteCompletedFigureAgreesWithListHeading(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
hinted := time.Now().Add(-72 * time.Hour).UnixMilli()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:hinted", url: "https://asurascans.com/comics/hinted", cover: "aaa", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(10), bookmarks: 1, siteCompletedAt: hinted})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:plain", url: "https://asurascans.com/comics/plain", cover: "bbb", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||
srv := newRouter(st, testConfig())
|
||||
body := overviewBody(t, srv, st)
|
||||
|
||||
fig := regexp.MustCompile(`href="/admin/series\?filter=site_completed">(\d+)</a>`).FindStringSubmatch(body)
|
||||
if fig == nil {
|
||||
t.Fatalf("overview has no site-completed figure:\n%s", body)
|
||||
}
|
||||
list := adminSeriesPage(t, srv, st, "?filter=site_completed")
|
||||
heading := regexp.MustCompile(`(\d+) series <span`).FindStringSubmatch(list)
|
||||
if heading == nil {
|
||||
t.Fatalf("site-completed list has no heading count:\n%s", list)
|
||||
}
|
||||
if fig[1] != heading[1] {
|
||||
t.Errorf("the overview says %s, the list it links to says %s", fig[1], heading[1])
|
||||
}
|
||||
}
|
||||
|
||||
// A measured zero site-completed count stays on the page as a muted digit,
|
||||
// never a link: following it would land on an empty list, exactly like every
|
||||
// other zero figure.
|
||||
func TestOverviewSiteCompletedZeroIsAnUnlinkedDigit(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:one", url: "https://asurascans.com/comics/one", cover: "aaa", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||
srv := newRouter(st, testConfig())
|
||||
body := overviewBody(t, srv, st)
|
||||
|
||||
if !strings.Contains(body, `Site says completed</span><span class="fig zero">0</span>`) {
|
||||
t.Errorf("a zero site-completed figure does not render as a muted digit:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, `href="/admin/series?filter=site_completed"`) {
|
||||
t.Errorf("a zero site-completed figure is still a link:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A measured zero finished count stays on the page as a muted digit, never a
|
||||
// link: following it would land on an empty list, exactly like every other
|
||||
// zero figure.
|
||||
@@ -3165,8 +3482,10 @@ func TestOverviewPerSiteTableLinksToNarrowedLists(t *testing.T) {
|
||||
srv := newRouter(st, testConfig())
|
||||
body := overviewBody(t, srv, st)
|
||||
|
||||
// The landed grid pins six columns; the header states them in order.
|
||||
if !strings.Contains(body, `<div class="thead"><span>Site</span><span>Series</span><span>No cover</span><span>Never chk</span><span>Stale</span><span>State</span></div>`) {
|
||||
// The landed grid pins six columns; the header states them in order. Cell
|
||||
// attributes (tooltips) are not part of the contract, the words are.
|
||||
thead := regexp.MustCompile(`<div class="thead"><span[^>]*>Site</span><span[^>]*>Series</span><span[^>]*>No cover</span><span[^>]*>Never checked</span><span[^>]*>Stale</span><span[^>]*>Status</span></div>`)
|
||||
if !thead.MatchString(body) {
|
||||
t.Errorf("per-Site table is not the six pinned columns:\n%s", body)
|
||||
}
|
||||
// The Site label is a door to the Site-narrowed list.
|
||||
@@ -3574,6 +3893,94 @@ func TestAdminSeriesDetailFinishControl(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The hint line rides the Finish control: present for a Series the Site
|
||||
// marked completed, with its age, absent while the Site has said nothing
|
||||
// (zero stamp) and gone once the owner finishes — the Site's stamp keeps
|
||||
// standing after retirement, so the hint is suppressed by the same
|
||||
// finished_at guard the clock-driven filters carry (issue #170).
|
||||
func TestAdminSeriesDetailSiteCompletedHint(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
hinted := time.Now().Add(-72 * time.Hour).UnixMilli()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:hinted", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1, siteCompletedAt: hinted})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:plain", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:retired", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1, siteCompletedAt: hinted})
|
||||
if err := st.SetSeriesFinished("asura", "retired", time.Now().UnixMilli()); err != nil {
|
||||
t.Fatalf("finish asura:retired: %v", err)
|
||||
}
|
||||
srv := newRouter(st, testConfig())
|
||||
|
||||
body := seriesDetailPage(t, srv, st, "asura:hinted")
|
||||
if !strings.Contains(body, "the site says this work is completed (since 3d ago)") {
|
||||
t.Errorf("hinted Series lacks the hint with its age:\n%s", body)
|
||||
}
|
||||
// The hint sits between the Finish opener and the confirm row — a line
|
||||
// beside the control, not the control itself.
|
||||
opener := strings.Index(body, "document.getElementById('confirm-finish').hidden = false")
|
||||
hintAt := strings.Index(body, "the site says this work is completed")
|
||||
confirmAt := strings.Index(body, `id="confirm-finish"`)
|
||||
if opener < 0 || hintAt < 0 || confirmAt < 0 || hintAt < opener || confirmAt < hintAt {
|
||||
t.Errorf("the hint is not a line beside the Finish control (opener %d, hint %d, row %d):\n%s", opener, hintAt, confirmAt, body)
|
||||
}
|
||||
if strings.Contains(seriesDetailPage(t, srv, st, "asura:plain"), "the site says this work is completed") {
|
||||
t.Errorf("an unmarked Series renders the hint:\n%s", body)
|
||||
}
|
||||
if strings.Contains(seriesDetailPage(t, srv, st, "asura:retired"), "the site says this work is completed") {
|
||||
t.Errorf("a finished Series renders the hint:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The hint is a line of text, never a control: with and without a hint, the
|
||||
// Finish control's markup is byte-identical (AC4). Two stores seed the same
|
||||
// key so the hx-post URLs match byte for byte; only the hint line differs,
|
||||
// and it sits outside the confirm row.
|
||||
func TestAdminSeriesDetailFinishControlUnchangedWithHint(t *testing.T) {
|
||||
hinted := time.Now().Add(-72 * time.Hour).UnixMilli()
|
||||
var bodies [2]string
|
||||
for i, sc := range []int64{hinted, 0} {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:solo", url: "u", cover: "c", checkedAt: time.Now().UnixMilli(), latestNum: floatPtr(1), bookmarks: 1, siteCompletedAt: sc})
|
||||
db.Close()
|
||||
srv := newRouter(st, testConfig())
|
||||
bodies[i] = seriesDetailPage(t, srv, st, "asura:solo")
|
||||
}
|
||||
withHint, without := bodies[0], bodies[1]
|
||||
if !strings.Contains(withHint, "the site says this work is completed") {
|
||||
t.Fatalf("the hinted store did not render a hint:\n%s", withHint)
|
||||
}
|
||||
if strings.Contains(without, "the site says this work is completed") {
|
||||
t.Fatalf("the plain store rendered a hint:\n%s", without)
|
||||
}
|
||||
if got, want := confirmRowMarkup(t, withHint), confirmRowMarkup(t, without); got != want {
|
||||
t.Errorf("the Finish control's markup changed with a hint present:\nwith hint: %s\nwithout: %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// confirmRowMarkup returns the confirm-finish row's HTML, byte for byte —
|
||||
// the Finish control AC4 says must not change with a hint present. The row
|
||||
// is the outer div (the span plus the inner button pair), with no nested
|
||||
// divs beyond that, so the first two </div> closes end it.
|
||||
func confirmRowMarkup(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
start := strings.Index(body, `<div class="confirm-row calm" id="confirm-finish"`)
|
||||
if start < 0 {
|
||||
t.Fatalf("no confirm-finish row in:\n%s", body)
|
||||
}
|
||||
open := strings.Index(body[start:], ">") + start + 1
|
||||
inner := strings.Index(body[open:], "</div>")
|
||||
outer := strings.Index(body[open+inner+len("</div>"):], "</div>")
|
||||
end := open + inner + len("</div>") + outer + len("</div>")
|
||||
return body[start:end]
|
||||
}
|
||||
|
||||
// The finish route is the owner's one writer: a finish press stamps
|
||||
// finished_at and answers with the swapped detail-meta fragment carrying the
|
||||
// "finished <age> ago" mark, and an un-finish press writes zero and answers
|
||||
@@ -3823,8 +4230,8 @@ func TestSeriesRemoveRendersOnlyOnOrphans(t *testing.T) {
|
||||
router := newRouter(st, testConfig())
|
||||
|
||||
body := adminSeriesPage(t, router, st, "")
|
||||
if got := strings.Count(body, ">Remove<"); got != 1 {
|
||||
t.Errorf("list offers Remove %d times, want 1 (only the orphan):\n%s", got, body)
|
||||
if got := strings.Count(body, ">Remove<"); got != 2 {
|
||||
t.Errorf("list offers Remove %d times, want 2 (opener + confirm for orphan):\n%s", got, body)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
key string
|
||||
@@ -3889,6 +4296,52 @@ func TestRemoveFromListAnswersRowAndFreshHeading(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The removal answers two fragments on one response. The second is an
|
||||
// out-of-band append, so it must not send a second status line: net/http
|
||||
// answers a double WriteHeader with "superfluous response.WriteHeader call"
|
||||
// on the server's error log, which a ResponseRecorder never sees. Hence a
|
||||
// real server here.
|
||||
func TestRemoveFromListSendsOneStatusLine(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:a", url: "u", checkedAt: 9000, bookmarks: 0})
|
||||
|
||||
var errLog bytes.Buffer
|
||||
srv := httptest.NewUnstartedServer(newRouter(st, testConfig()))
|
||||
srv.Config.ErrorLog = log.New(&errLog, "", 0)
|
||||
srv.Start()
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, srv.URL+"/admin/series/asura:a/remove",
|
||||
strings.NewReader("filter=no_readers&band=0"))
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
resp, err := srv.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("removal request: %v", err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("removal status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
// Both fragments still travel: the fix must not have dropped the heading.
|
||||
if !strings.Contains(string(body), "Title of asura:a") ||
|
||||
!strings.Contains(string(body), `hx-swap-oob="true"`) {
|
||||
t.Errorf("answer lost a fragment:\n%s", body)
|
||||
}
|
||||
if strings.Contains(errLog.String(), "superfluous") {
|
||||
t.Errorf("removal wrote the status line twice: %s", errLog.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A removal from the detail page navigates to the No-Readers list: htmx gets
|
||||
// a full navigation (HX-Redirect — a 303 would be followed by the request
|
||||
// and the list page swapped into the press's target), plain clients the 303
|
||||
@@ -3965,7 +4418,7 @@ func TestRemoveRacedBookmarkIsRefusedNotError(t *testing.T) {
|
||||
if !strings.Contains(body, "a Reader has bookmarked this Series again") {
|
||||
t.Errorf("refusal does not say what happened:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `class="c-rd">1</span>`) {
|
||||
if !strings.Contains(body, `data-label="readers">1</span>`) {
|
||||
t.Errorf("refusal does not render the fresh count:\n%s", body)
|
||||
}
|
||||
var one int
|
||||
@@ -4035,3 +4488,49 @@ func TestRemoveRejectsBadKeysAndCapsBody(t *testing.T) {
|
||||
t.Errorf("an oversized body still removed the row:\n%s", list)
|
||||
}
|
||||
}
|
||||
|
||||
// The failing pair render in the stats block from the same aggregate the
|
||||
// select is numbered from: failing over 12h counts every Series with a
|
||||
// failure row older than the cutoff, unverified the Reader-attributed
|
||||
// subset, and a measured zero stays a muted digit — never a door.
|
||||
func TestOverviewFailingAndUnverifiedFigures(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
now := time.Now().UnixMilli()
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:broken", url: "u", cover: "c", checkedAt: now, latestNum: floatPtr(1), bookmarks: 1})
|
||||
seedSeriesRow(t, st, db, seriesRowSeed{key: "asura:reader", url: "u", cover: "c", checkedAt: now, latestNum: floatPtr(2), bookmarks: 1, raisedBy: true})
|
||||
if err := st.RecordSeriesFailure("asura", "broken", "not_found", now-24*3600*1000); err != nil {
|
||||
t.Fatalf("seed failure row on broken: %v", err)
|
||||
}
|
||||
if err := st.RecordSeriesFailure("asura", "reader", "not_found", now-24*3600*1000); err != nil {
|
||||
t.Fatalf("seed failure row on reader: %v", err)
|
||||
}
|
||||
srv := newRouter(st, testConfig())
|
||||
body := overviewBody(t, srv, st)
|
||||
|
||||
if !strings.Contains(body, `href="/admin/series?filter=failing">2</a>`) {
|
||||
t.Errorf("the failing figure lacks its count and door:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `href="/admin/series?filter=unverified">1</a>`) {
|
||||
t.Errorf("the unverified figure lacks its count and door:\n%s", body)
|
||||
}
|
||||
|
||||
empty, emptySt := newWebTestServer(t, testConfig())
|
||||
body = overviewBody(t, empty, emptySt)
|
||||
if !strings.Contains(body, `Failing over 12h</span><span class="fig zero">0</span>`) {
|
||||
t.Errorf("a zero failing figure does not render as a muted digit:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `Unverified Reader number</span><span class="fig zero">0</span>`) {
|
||||
t.Errorf("a zero unverified figure does not render as a muted digit:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, `href="/admin/series?filter=failing"`) {
|
||||
t.Errorf("a zero failing figure is still a link:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, `href="/admin/series?filter=unverified"`) {
|
||||
t.Errorf("a zero unverified figure is still a link:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -45,11 +45,13 @@ services:
|
||||
# in; distroless already carries tzdata, so the name just resolves.
|
||||
TZ: ${API_TZ:-Asia/Jakarta}
|
||||
# Discord OAuth for the browser UI (ADR-0002). The first four are
|
||||
# required; DISCORD_REQUIRED_ROLE is optional and empty by default.
|
||||
# Guild membership is the whole gate: any member becomes a Reader.
|
||||
# required; DISCORD_REQUIRED_ROLE and DISCORD_GUILD_NAME are optional and
|
||||
# empty by default. Guild membership is the whole gate: any member
|
||||
# becomes a Reader.
|
||||
DISCORD_CLIENT_ID: ${DISCORD_CLIENT_ID:?set DISCORD_CLIENT_ID in .env}
|
||||
DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET:?set DISCORD_CLIENT_SECRET in .env}
|
||||
DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:?set DISCORD_GUILD_ID in .env}
|
||||
DISCORD_GUILD_NAME: ${DISCORD_GUILD_NAME:-}
|
||||
DISCORD_REQUIRED_ROLE: ${DISCORD_REQUIRED_ROLE:-}
|
||||
DISCORD_API_BASE: ${DISCORD_API_BASE:-https://discord.com/api/v10}
|
||||
DISCORD_REDIRECT_URI: ${DISCORD_REDIRECT_URI:?set DISCORD_REDIRECT_URI in .env}
|
||||
@@ -76,6 +78,12 @@ services:
|
||||
# independent of chromedp's own dial logic. The same trap that used to
|
||||
# force a pinned Docker IP now forbids the tailnet name.
|
||||
BROWSER_WS_URL: ${BROWSER_WS_URL:-}
|
||||
#
|
||||
# Owner-notice webhook (issue #171). Empty default, never a
|
||||
# required-guard: unset means the whole path is off, so a local stack
|
||||
# runs exactly as it does today. An env var not listed here never
|
||||
# reaches the container.
|
||||
DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL:-}
|
||||
depends_on:
|
||||
# The migration runner is the first thing the binary does, so a Postgres
|
||||
# that is still initialising means a crash-loop until it is not.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# ADR-0016: The failure row is the state
|
||||
|
||||
Date: 2026-08-22
|
||||
Status: accepted
|
||||
|
||||
## Decision
|
||||
|
||||
A Series that used to work and has stopped is recorded in one table,
|
||||
`poll_failures`, keyed by the same `(site, series_id)` composite the rest of
|
||||
the system uses. One row per failing Series, carrying the outcome word and a
|
||||
`failing_since` stamp. The row's **existence** is the failure state: there is
|
||||
no success sentinel, no counter, no history, and nothing added to the Series
|
||||
row. A correct read deletes the row, and a repeated identical failure writes
|
||||
nothing — the stamp ages the run of failures, not the current word.
|
||||
|
||||
The write is one upsert, from the poll pass loop:
|
||||
|
||||
```sql
|
||||
INSERT INTO poll_failures (site, series_id, outcome, failing_since)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (site, series_id) DO UPDATE SET outcome = EXCLUDED.outcome
|
||||
WHERE poll_failures.outcome <> EXCLUDED.outcome
|
||||
```
|
||||
|
||||
`failing_since` is simply absent from the `SET` arm, so it is never
|
||||
overwritten, and the `WHERE` makes an unchanged word write nothing at all —
|
||||
one statement, no read-then-write race. The clear is a plain `DELETE`; a row
|
||||
that is not there is not an error, because the poller clears on every
|
||||
successful read and most clears find nothing. The composite foreign key
|
||||
cascades: deleting a Series takes its failure row, so orphan removal stays a
|
||||
single statement.
|
||||
|
||||
## Why a future reader will find this surprising
|
||||
|
||||
The failure state lives in a table of its own, not on the Series row and not
|
||||
in the pass log. A flag on `series` would be the familiar shape, and the
|
||||
pass log already records outcomes per pass — why a third place?
|
||||
|
||||
Because the failure must outlive the pass that observed it and mean
|
||||
something the pass row cannot say. The pass log is a per-Lane stream: it
|
||||
records that a Site returned N `errors` and M `not_found` on a given run,
|
||||
but "this exact Series has been failing for three months" is not a question
|
||||
any single pass row answers — it is a question across rows, and the Lanes
|
||||
page is a live view of the last 14 days, not a Series index. A Series-level
|
||||
fact needs Series-level storage, and a flag on the Series row is the wrong
|
||||
shape too: the failure is *transient by definition* (a correct read ends it)
|
||||
and *repeatable* (the same Series can fail again next year), so the honest
|
||||
record of "failing since" is a stamp that moves, not a column that flips.
|
||||
A row that is created and deleted by the read's outcome is that stamp, and
|
||||
nothing else: the moment a read succeeds the row is gone, so "is this Series
|
||||
failing?" is answered by one indexed existence check — no success sentinel
|
||||
to keep consistent with the failure, no counter to reset, no history to
|
||||
prune. The state cannot drift out of step with the reads that maintain it,
|
||||
because the reads *are* the maintenance.
|
||||
|
||||
The two no-write outcomes are the second surprise. `refused` (the Site is
|
||||
holding a challenge) and `unreachable` (the browser sidecar was lost
|
||||
mid-loop) issue **no statement at all** — neither an upsert nor a delete.
|
||||
The direction matters, and both directions are wrong to touch:
|
||||
|
||||
- **Writing would condemn a whole library for one Site's bad day.** A
|
||||
refusal is the Site's mood, not a fact about any particular Series: when
|
||||
Cloudflare turns a zone's JS detection on, every Series on that Site reads
|
||||
refused in the same pass. Writing those rows would stamp every Series in
|
||||
the library as failing on the evidence of one Site's configuration, and
|
||||
#166's worklist would present a Site-wide outage as thousands of broken
|
||||
Series.
|
||||
- **Deleting would claim a recovery nothing read.** A lost sidecar tells us
|
||||
nothing about the page — the read never happened. Deleting the row would
|
||||
report the Series healthy, and worse, it would reset `failing_since`: the
|
||||
age that makes a three-month failure findable would start over on a
|
||||
browser restart, erasing evidence no page read contradicted.
|
||||
|
||||
So a refusal or an unreachable pass leaves the table exactly as it found
|
||||
it — the pass neither adds rows that would condemn nor deletes rows that
|
||||
would claim recovery. The four outcomes that are real evidence about the
|
||||
page (`not_found`, `no_chapter`, `unfetchable`, `errors`) write; the two
|
||||
that are not write nothing; success deletes. There is no third case.
|
||||
|
||||
## Considered options
|
||||
|
||||
**A `failing_since` column on the Series row, alongside the failure word.**
|
||||
Rejected: it is two more columns on a table every due query and every
|
||||
bookmark join already touches, for a state that is transient and repeatable.
|
||||
The column would need its own "clear on success" writer anyway — the same
|
||||
maintenance the row has — while permanently widening the hottest table in
|
||||
the system for a value that is usually absent. And the worklist's join
|
||||
would have to distinguish "never failed" from "currently healthy", which
|
||||
is exactly the sentinel problem the row avoids: an empty column means both.
|
||||
|
||||
**A counter or last-outcome timestamp instead of (or beside) the row.**
|
||||
Rejected: nothing in the system consumes a failure *count* or a
|
||||
last-outcome time — the worklist needs "failing and since when". A counter
|
||||
invites "three failures = something" thresholds that the ticket explicitly
|
||||
keeps out of scope, and a last-outcome timestamp conflates "the word
|
||||
changed" with "the run restarted", which the age is deliberately defined
|
||||
against. The stamp is the only number that means anything, and the row
|
||||
carries exactly that.
|
||||
|
||||
**Write into `poll_passes` and derive the failure state from the pass
|
||||
stream.**
|
||||
Rejected: a pass row is per-Lane and per-run; deriving "this Series is
|
||||
failing since" means scanning 14 days of outcome counts per Series and
|
||||
guessing at continuity across retention boundaries. The pass log answers
|
||||
"what did this Site's lane do recently"; the failure table answers "which
|
||||
Series are broken right now". Two questions, two tables — the pass log is
|
||||
already pruned on a fixed cutoff, which would silently reset every
|
||||
failure's age the moment the evidence aged out.
|
||||
|
||||
**Refused/unreachable write nothing, but the delete happens anyway (or the
|
||||
upsert happens, with no delete).**
|
||||
Rejected in both directions, above: writing condemns a library for one
|
||||
Site's mood; deleting claims a recovery nothing read and resets the age
|
||||
that makes a long failure findable. The asymmetry is the point — the two
|
||||
outcomes are evidence about the *environment*, never about a page.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The poller writes from the pass loop, one call after `checkOne`, and
|
||||
clears through the ordinary success path: a Forced Poll that reads the
|
||||
page deletes the row with no forced branch of its own, and a Forced Poll
|
||||
*request* — which only stamps the request — clears nothing.
|
||||
- `poll_failures` is a poll-write table like `poll_lanes` and
|
||||
`poll_passes`: the store's two methods live next to the Lane-pass
|
||||
writers, and neither runs on a request path. A store failure is logged
|
||||
and the poll continues — a failure row is best-effort, and no single bad
|
||||
Series may stall a Lane.
|
||||
- The due query does not join the table. A failing Series is polled at the
|
||||
same pace as any other, because the moment the query started pacing by
|
||||
failure state, the age would stop meaning what the worklist reads it as.
|
||||
- Orphan removal stays a single statement: the composite foreign key's
|
||||
`ON DELETE CASCADE` is what takes the failure row with the Series.
|
||||
- The table starts empty at deploy, so nothing is findable for the first
|
||||
twelve hours after deploy and a pre-existing breakage reads as new.
|
||||
Accepted; the alternative is inventing history.
|
||||
|
||||
## Cost of reversing
|
||||
|
||||
The failure state is derived, not stored: a rollback drops the table and
|
||||
every in-progress failure age with it, leaving the pass log's outcome
|
||||
counts as the only trace — which is exactly the "log line nobody reads"
|
||||
the ticket set out to replace. Recreating the table later starts the ages
|
||||
over, so a reversal that is later reversed loses the evidence of the
|
||||
intervening failures. The failure state itself, however, is the one thing
|
||||
that is *not* lost by reversing: it is re-derived from the next pass, in
|
||||
the same direction the original design derives it — the row is
|
||||
recreated by the next failing read and deleted by the next good one.
|
||||
+42
-8
@@ -128,8 +128,11 @@ Recurring specs (copy these rather than inventing sizes):
|
||||
- Tab: `400 17px display` (`18px` ≥720px), active gets `border-bottom: 2px` in
|
||||
`--paper` (`--ember` for Updated) plus `margin-bottom: -1px` so it lands on
|
||||
the row's own hairline.
|
||||
- Meta / label / badge / action key: `500 10–11px mono`, `letter-spacing:
|
||||
- Meta / label / badge / action key: `500 11px mono`, `letter-spacing:
|
||||
.04em`–`.2em`, `text-transform: uppercase`. Eyebrows use the widest tracking.
|
||||
**11px is the floor** — nothing in this UI sets mono below it. The brief names
|
||||
night reading and glare as the usage scene, and a 10px small-caps label at
|
||||
arm's length on a phone is where that scene stops being served.
|
||||
- Empty-state heading: `400 20px display`; body `400 14px/1.6 sans`, `max-width: 44ch`.
|
||||
- Primary button: `--paper` fill, `--ink` text, `400 17–19px display`, no border radius.
|
||||
- Ghost button: mono small-caps, transparent, `border-bottom: 1px --field-line`.
|
||||
@@ -140,11 +143,20 @@ Recurring specs (copy these rather than inventing sizes):
|
||||
.sheet
|
||||
.topbar .brand (mark + wordmark) + .ghost (log out)
|
||||
.chrome .searchbar + nav.tabs (column on phone, row ≥720px via order:)
|
||||
sticky at top: 0, z-index 2, on an --ink ground
|
||||
.keyrow one-line action key: Read / Fav / Chapter / Archive / Done / Delete
|
||||
.recent h2 eyebrow + .recent-strip > a.recent-card
|
||||
main#list article.card … | .empty
|
||||
```
|
||||
|
||||
**Sticky chrome.** Search and the tab row are the two controls a 300-item
|
||||
library needs mid-scroll, so `.chrome` alone sticks (`padding-top:
|
||||
env(safe-area-inset-top)` for the notch cutout). Everything above it —
|
||||
`.topbar`, `.keyrow`, `.recent` — scrolls away on purpose: another 150px of
|
||||
permanent chrome on an 844px phone costs more than re-scrolling for an icon
|
||||
reminder. If header height ever grows, unstick `.recent`/`.keyrow` further
|
||||
rather than adding to the sticky region, and keep `.chrome` above the cards.
|
||||
|
||||
The owner's admin page (`admin.html`) is the same sheet with two sections in
|
||||
place of the list — `.lanes` (Poll Lane rows) and `.readers` (the roster) —
|
||||
and no library switch: it belongs to neither library, so its topbar carries a
|
||||
@@ -225,9 +237,16 @@ Rules that are easy to break:
|
||||
- `[hidden] { display: none !important; }` is load-bearing — every disclosure
|
||||
panel is a flex container, and `display` beats `hidden`.
|
||||
- Busy state is `.card.htmx-request::before`, a 1px grey bar sliding across the
|
||||
top hairline (`barSlide`), plus the action strip at `opacity: .5`. Never a
|
||||
spinner, and deliberately `--mute` not `--ember` — on a list screen ember
|
||||
means "new chapter" and nothing else, so a system state can't borrow it.
|
||||
top hairline (`barSlide`), the action strip at `opacity: .5`, and past 2s the
|
||||
word `Saving…` in `::after` (`busyWord`, §6). `filter.js` sets `aria-busy` on
|
||||
the card over the same window because htmx sets none, so the wait is not
|
||||
silent to a screen reader. Never a spinner, and deliberately `--mute` not
|
||||
`--ember` — on a list screen ember means "new chapter" and nothing else, so a
|
||||
system state can't borrow it.
|
||||
- Titles clamp at 3 lines (`.recent-title` at 2 — there the title is a
|
||||
reminder, in the list it is the identifier). `.is-new .title` needs
|
||||
`width: fit-content`, or `-webkit-box` stretches the ember underline to the
|
||||
full row and the rule stops being sized to the text.
|
||||
- `.open` on the pencil / lifecycle cell marks which panel is showing;
|
||||
`filter.js` `togglePanel()`/`toggleConfirmRow()` own that class alongside
|
||||
`hidden`. An open lifecycle cell needs the next surface step up from
|
||||
@@ -251,7 +270,7 @@ is a 1px bar, not a rotating ring); toasts are `--ash` with a 2px left rule,
|
||||
|
||||
## 6. Motion
|
||||
|
||||
Three animations, all ≤ 1.15s and all disabled under
|
||||
Four animations, all ≤ 1.15s and all disabled under
|
||||
`prefers-reduced-motion: reduce` (pseudo-elements need naming explicitly in
|
||||
that query — `*` does not match `::before`/`::after`, so the busy bar and
|
||||
error dot are listed by name and fall back to their static drawn form):
|
||||
@@ -259,16 +278,29 @@ error dot are listed by name and fall back to their static drawn form):
|
||||
- `sheetIn` — 180ms fade + 4px rise, on a row and on each disclosure panel.
|
||||
- `barSlide` — the sliding hairline, for any busy state.
|
||||
- `mutePulse` — the 5px dot on `.error-inline`.
|
||||
- `busyWord` — `0s 2s forwards`, a delay rather than a motion: it reveals the
|
||||
`Saving…` word only once a request has outlived a plausible response. The
|
||||
reduced-motion block cancels the animation and so would pin it at
|
||||
`opacity: 0`; that branch re-declares `opacity: 1` to show it from the start.
|
||||
Any future state revealed this way needs the same two-line pair.
|
||||
|
||||
No transforms on hover, no scale, no easing curves beyond `ease-out`/`linear`.
|
||||
|
||||
## 7. Accessibility floor (not negotiable)
|
||||
|
||||
- Touch targets on the phone layout are 44–46px; the 44px desktop cells are
|
||||
pointer-only (≥720px).
|
||||
- Every icon-only control keeps `title` + `aria-label`; the SVG inside is
|
||||
`aria-hidden`. Lifecycle buttons also carry `aria-expanded` +
|
||||
`aria-controls` pointing at their `.confirm-row`.
|
||||
- **Every focusable control carries a visible ring**: `outline: 2px solid
|
||||
var(--paper)` with `2px` offset on `:focus-visible`, since the UA default is
|
||||
a bright blue tuned for neither branch of this palette. `.searchbar` recolours
|
||||
its border on `:focus-within` as a resting cue, but that 1px change is not the
|
||||
ring — the `.search` input declares its own. A new control that suppresses
|
||||
`outline` must replace it, not drop it.
|
||||
- Touch targets are 44–46px under `(pointer: coarse)` — including inline text
|
||||
controls like `.libswitch a`, where padding plus an 11px line lands short of
|
||||
44 and needs `min-height` + `place-items: center`. The 44px desktop cells are
|
||||
pointer-only (≥720px).
|
||||
- The cover link is `tabindex="-1" aria-hidden="true"` because the title link
|
||||
and the play cell already reach the same URL — do not make it a third tab stop.
|
||||
- Tabs keep `role="tab"` / `role="tablist"`; the active one is marked by class,
|
||||
@@ -292,7 +324,9 @@ No transforms on hover, no scale, no easing curves beyond `ease-out`/`linear`.
|
||||
5. Icon → `templates/icons.html`; nothing inlines SVG paths. Brand mark stays
|
||||
the one exception (`chrome.html`'s `mark` template), since it takes
|
||||
page-level custom properties the sprite can't carry per-instance.
|
||||
6. Phone first (44px targets, single column), then the ≥720px block.
|
||||
6. Phone first (44px targets under `(pointer: coarse)`, single column), then the
|
||||
≥720px block. Mono no smaller than 11px, and a `:focus-visible` ring on
|
||||
anything focusable — both are §7 floors, not preferences.
|
||||
7. Verify: `cd backend && go test ./...`, then run the binary and screenshot
|
||||
both widths and both colour schemes (Playwright: `emulateMedia`,
|
||||
`setViewportSize`; disable the browser cache — `/static/*` is served with
|
||||
|
||||
Reference in New Issue
Block a user