Uncommitted work from three design runs on this branch, against one design
system: docs/design-system.md is updated to match the CSS, not the reverse.
Library (Reader-facing):
- .chrome sticks at top: 0. Search and the tab row were unreachable three
screens into a 300-item library, which is exactly where they earn their
keep; everything above them still scrolls away on purpose.
- One :focus-visible ring (2px --paper) on the nine controls that defined
none and fell back to the UA blue. .searchbar keeps its border recolour as
a resting cue but no longer stands in for a ring.
- Mono labels lift 10px -> 11px everywhere. The brief names night reading and
glare as the usage scene; 10px small-caps was where taste overrode it.
- A card in flight past 2s says "Saving..." and carries aria-busy. htmx sets
neither, so the wait up to its 15s timeout was silent in both channels.
- Titles clamp at 3 lines; .is-new .title takes width: fit-content, or
-webkit-box stretches the ember underline past the text it sizes to.
/admin:
- Overview routes into Lanes when a lane is unhealthy, prefixes each figure
with its column word on the phone layout that drops the thead, labels state
cells for a screen reader, and has an empty state where the sites table
assumed rows.
- The admin shell picks up the library's chrome: htmx 15s timeout, the shared
#notice slot, #sr-announce, filter.js. admin.css follows the same pass.
- admin_render_test.go and card_render_test.go render the templates directly,
so markup regressions in either surface fail without a browser.
Login:
- DISCORD_GUILD_NAME (optional) names the community on the login screen and
in the refusal message, so a stranger knows which Discord to ask for an
invite. Unset degrades to a generic label; neither form names the guild id.
Handlers:
- maxChapterNum (9999) bounds both typed-chapter paths. uiChapter and
adminSeriesCorrectLatest each parsed a float64 with no ceiling, so a
hand-rolled POST stored 1e308 and every later reader of that row inherited
it. Matches the max on the card's chapter input.
go test ./... green.
adminSeriesRemove answered the list surface with two h.render calls -- the
row fragment and the out-of-band heading -- and h.render writes a status
line each time, so every removal logged "superfluous
response.WriteHeader call". The heading is an append to a response
already committed, so it now executes straight onto w, the way
writeChromeOOB already does it.
The regression test runs the router under a real server with a captured
ErrorLog: a ResponseRecorder never sees this warning, which is why the
existing removal test did not catch it.
Implements spec #137 (spec 4 of 4 from wayfinder map #114).
Closes#137.
Tickets: #164, #165, #166, #167, #168, #169, #170, #171, #172, #173 — all closed, landed on this branch.
## Summary
- #164/#168: sixth outcome word `not_found`; per-Site completed marker predicate.
- #165/#169: `poll_failures` row is the failure state; the pass remembers a Site-reported completion.
- #166/#171: two new admin filters (`failing`, `unverified`); outbound owner notification + stall condition.
- #167/#170: a failure names itself on the Series page; the completion hint reaches the owner and decides nothing.
- #172: the other three fault conditions (no-browser-route, sidecar-down, adapter-broken) feeding the notifier.
- #173: the landing verdict line shares the same `latest.FaultsFrom` judgement the notifier uses, so the page and the push cannot disagree.
`cd backend && go test ./...` green on the merged branch (8 packages).
Reviewed-on: #174
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
Closes#136.
Spec #136 end to end: `finished` becomes a fact about the Series, written only by the owner, and the reader-facing Lifecycle bucket is gone.
## What landed
- **#157** — `series.finished_at bigint NOT NULL DEFAULT 0` plus the migration whose statement order is load-bearing (seed from the buckets, then flip them); both Lane queries lose the `HAVING COUNT(*) FILTER (WHERE b.status <> 'finished')` clause and gate on `finished_at = 0` instead, with the due-query/eligible-count force asymmetry kept deliberate and commented; `StatusFinished`, its API special-case 400, the web tab and the templates' Finished bucket deleted.
- **#158** — owner Finish control on the Series detail page: confirm-gated finish, instant un-finish, admin accent (never ember, nothing is destroyed), `Store.SetSeriesFinished`, the two routes behind the owner gate, and the state displayed on the list row without offering the control there.
- **#160** — reader side: derived `finished` bool on the flat Bookmark (`s.finished_at > 0`), rendered as a text-only label in both userscripts and on the web card; read-only inbound by omission from `Upsert`'s explicit `series` column list, same mechanism that already protects `cover`.
- **#161** — glossary and the stale Reader-count divergence note catch up.
- **#159** — `finished` joins the admin filter vocabulary (predicate `finished_at > 0`, label `Finished`, own aggregate count, figure last in the stats block as informational); the four clock-driven hygiene predicates (stale, never-checked, no-cover, no-chapter) exclude finished Series while unpollable, orphan and sighting-raised deliberately do not.
## Verification
`go vet ./...` and `go test ./...` green on the merged branch (Docker-backed, throwaway `postgres:17-alpine` per package). Each ticket also passed a two-axis review (spec + standards) on its own branch before merge.
Reviewed-on: #163
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
Implements spec #135 (spec 2 of 4, derived from wayfinder map #114; decisions settled in #120/#121/#125/#131). Blocked-by #134 is merged, so this lands on `main`.
Four owner actions the dashboard can now perform, one ticket each:
- **#149** — Latest Chapter correction: one numeric input, overwritten by the next machine write.
- **#151** — Series URL repair: owner-typed, gated by the poller's own fetch gate.
- **#150 / #153 / #154** — Cover replacement: addresses derived from bytes (`#150`), a Forced Poll replaces the Cover while an ordinary pass still only fills a blank one (`#153`), and byte reclamation is one guarded helper, file first / covers row last (`#154`).
- **#155** — Orphan removal: one Series at a time, with the foreign key as the guard.
Plus **#152** — Latest Chapter provenance: one derived line naming the actor class, so an owner can tell a hand-edited number from a machine read.
- Migration `0015_latest_correction.sql` adds the correction/provenance columns; `0009` now derives cover addresses from bytes.
- ADR `0014-cover-addresses-from-bytes.md` records the address scheme.
Backend tests cover the store, poller, admin handlers, and web routes (`go test ./...`, needs Docker).
Reviewed-on: #156
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
Spec #134, all ten tickets. Closes#134.
## What ships
The admin surface becomes four bookmarkable addresses behind one nav row, and Lane observability stops dying with the process.
- **#138** `/admin` splits into Overview, Lanes, Readers, Series, each a real route with the active tab underlined.
- **#139** `poll_passes` and `poll_lanes` land as durable tables with their store surface.
- **#140** cross-Series admin read model, with the privacy boundary in the projection: the Reader id that raised a Latest Chapter never leaves the store package.
- **#141** the poller records exactly one pass row per exit, with a skip reason and outcome counts.
- **#142** Series list: eight hygiene filters, Site and Library narrowing, paging — all of it in the query string, so a filtered list is a bookmark.
- **#143** Overview: a three-state verdict line and a stats block where every non-zero figure links to the list that counts it.
- **#144** per-Series detail page, keyed by the `site:series_id` composite the rest of the system already uses.
- **#145** the Lanes page reads the database; the in-memory Lane state, `web.LaneReporter` and `latest.Status` are deleted.
- **#146** Forced Poll: *Check now* stamps `series.force_poll_at` and never commands the poller.
- **#147** pause and resume one Site's Lane, with a mandatory 1h/6h/24h expiry.
## Shape of the design
Two decisions carry the rest. **Commands go through the database, never at the poller**: both *Check now* and a Lane pause write a row the next pass reads, so they survive a restart and the whole surface stays testable with no poller running. And **pending is derived, never stored** — the request stamp being newer than the check stamp — which self-clears on the check stamp with no second write and no sweeper, because the check stamp is written before the fetch.
ADRs: `docs/adr/0012-persisted-lane-state.md`, `docs/adr/0013-commands-through-the-database.md`.
## Verification
`go test ./...` green on the merged base (`264839e`), all packages, Docker-backed. `gofmt -l` and `go vet` clean.
Every ticket was reviewed on both axes (`cr-spec` + `cr-standards`) before merge.
## Known, non-blocking
- **#143** the verdict ignores never-reported Lanes when other Lanes have reported, and the per-Site table lists Sites that have Series rather than the whole registry. The ticket prose asks for eight hygiene figures per Site; the design mock and the landed `.tbl.sites` grid both say six columns, and the mock won.
- **#146** two `SeriesPage` scans per press instead of a keyed read — `ponytail:`-commented in-tree with the upgrade path.
- **#147** a paused Site with no pass row yet renders no row and so no control, since the Lanes page lists Sites that have passed.
- **#141** a sibling browser Lane declining at the top of a pass records as `sidecar-down`. Specified deliberately; the later spec in this series settles it.
Reviewed-on: #148
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>