diff --git a/docs/research/htmx-filterable-admin-series-list.md b/docs/research/htmx-filterable-admin-series-list.md
new file mode 100644
index 0000000..70590ae
--- /dev/null
+++ b/docs/research/htmx-filterable-admin-series-list.md
@@ -0,0 +1,344 @@
+# htmx + filter.js — how far they carry a filterable, sortable, pageable owner-only Series list
+
+Research note for Gitea issue #123 (part of #114, admin dashboard spec): does the
+Series list's filtering/sorting/paging need anything new, or does the frontend
+already carry it?
+
+All facts fetched live on **2026-08-17**. Sources are the in-tree code
+(`backend/internal/web/…`, commit on `main` before this branch) and the official
+htmx 2.x documentation at `htmx.org` (docs, per-attribute pages, examples,
+migration guide, GitHub releases). Every claim carries a `file:line` or a URL.
+Interpretation rather than observation is marked `[INFERENCE]`.
+
+---
+
+## 1. Summary answer table
+
+| Question | Answer | Evidence |
+|---|---|---|
+| Vendored htmx version | **2.0.4** — the only version string in the file | §2.1 |
+| What `filter.js` does | **Pure client-side** title filter over a server-rendered list already in the DOM. It issues **no requests** and never touches the URL; the server never sees the query. | §2.2 |
+| Markup contract it depends on | `#search` input, `.card` articles with `data-title`, `#list` swap target, optional `.recent` strip and `#no-match` empty state | §2.2 |
+| URL-addressable filter/sort state | **Idiomatic htmx**: `hx-get` with query params + `hx-push-url` (or `hx-replace-url`). The swap alone never changes the address bar. Already proven in-tree by the tab links. | §3.1 |
+| Debounced search-as-you-type | **Idiomatic htmx**: `hx-trigger="keyup changed delay:500ms"` (or `input changed delay:…`). Zero JS. Server-driven, unlike the current client filter. | §3.2 |
+| Paging | **Idiomatic htmx**, no paging attribute exists. "Load more" = `hx-get` + `hx-swap="beforeend"` or `hx-trigger="revealed"`; numbered pages = `hx-get` + `hx-push-url` per page, exactly the tab pattern. They differ in swap style and URL semantics, not in features. | §3.3 |
+| Per-row action re-rendering one row | **Idiomatic htmx**: `hx-target` + `hx-swap="outerHTML"` (in-tree since the first card action); out-of-band chrome via `hx-swap-oob` (in-tree). | §3.4 |
+| Self-refresh beside a filtered list | **Idiomatic htmx**: `hx-trigger="every 30s"` scoped to its own block (the in-tree Lanes pattern), or list itself re-applies the client filter on `htmx:afterSwap` (already in filter.js). | §3.5 |
+| Confirm-gated row action | Both options already in-tree: `hx-confirm` (zero JS, `window.confirm`) and the hand-rolled inline confirm row. | §3.6 |
+| Does vendored 2.0.4 support every attribute cited? | **Yes** — verified in the vendored file itself, not assumed from latest docs | §4 |
+| New dependency implied | **None.** Everything needed is vendored; extensions are only relevant for drag-reorder (SortableJS), which column sort does not need. | §5 |
+
+---
+
+## 2. What the in-tree frontend actually does
+
+### 2.1 The htmx build
+
+`backend/internal/web/static/htmx.min.js` is **htmx 2.0.4**: the file's only
+version string is `version:"2.0.4"` (minified config object, first line of the
+file). GitHub release v2.0.4 was published 2024-12-13
+(`https://github.com/bigskysoftware/htmx/releases/tag/v2.0.4`); 2.0.0
+(2024-06-17) is the first 2.x. The current docs describe 2.0.10
+(`https://htmx.org/docs/#installing`), so version-specific claims are checked
+against 2.0.4 in §4 rather than assumed from today's docs.
+
+### 2.2 `filter.js` — a client-side filter over a rendered list, no requests
+
+The file's own header says it outright
+(`backend/internal/web/static/filter.js:1-2`):
+
+> "Title search runs entirely in the browser: the full list is already in the
+> DOM, so filtering it needs no request."
+
+Mechanics (`filter.js:4-32`): on any relevant event, `applyFilter()` reads the
+`#search` value, lowercases it, iterates every `.card`, and toggles each card's
+`hidden` attribute by substring-matching `card.dataset.title`
+(`filter.js:9-16`). It also hides the `.recent` strip while a query is active
+(`filter.js:21-25`, because "the strip is never filtered") and shows a
+server-rendered `#no-match` empty state when the query emptied the list
+(`filter.js:28-32`). No element is fetched, no request is issued, the query
+never leaves the browser, and the address bar never changes.
+
+Trigger wiring (`filter.js:35-49`):
+
+- `input` on `#search` → `applyFilter` (`filter.js:35-37`)
+- click on `.clear-search` → clear + refilter + refocus (`filter.js:39-42`)
+- `htmx:afterSwap` on `document.body` → re-apply (`filter.js:48`, comment at
+ `filter.js:47`: "htmx replaces the list on a tab switch, so re-apply to the
+ new cards")
+- custom `bmgr:refilter` event → re-apply (`filter.js:49`), dispatched by
+ `setActiveTab` (`filter.js:52-62`) because "the strip is outside the swapped
+ region"
+
+The rest of the file is disclosure and error handling, not filtering: the
+chapter form and archive/finish/remove **confirm rows** are server-rendered
+markup toggled open/closed by hand (`closeCardPanels` `filter.js:68`,
+`togglePanel` `filter.js:80`, `toggleConfirmRow` `filter.js:102-118`), and
+failed requests are surfaced inline via htmx's event API
+(`htmx:beforeRequest` `filter.js:157`, `htmx:responseError` `filter.js:172`,
+`htmx:sendError` `filter.js:185`, reason text read from `xhr.responseText`
+`filter.js:167`). The comment at `filter.js:153-155` notes "htmx does not swap
+on a non-2xx response" — the 2.x response-handling behaviour, already
+accounted for in-tree.
+
+**Markup contract** — what a server-rendered list must provide for filter.js:
+
+- `` (`backend/internal/web/templates/app.html:45`)
+- cards as ``
+ (`card.html:5`)
+- a `#list` container the list is swapped into (`app.html:87`, targeted by
+ `hx-target="#list"` at `app.html:55`)
+- optionally a `.recent` strip (`chrome.html:10`) and `#no-match`
+ (`list.html:8-11`)
+
+Scope note: filter.js is only loaded on the library page (`app.html:15-16`).
+The admin page loads htmx but **not** filter.js (`admin.html:16`), so a Series
+list on /admin that wanted the current client filter would first need filter.js
+included there, or the filter would go server-side instead.
+
+### 2.3 The server side of the same surface (read-only facts)
+
+- Tab list rendering is **already parameterised**: `GET /ui/list` reads `lib`
+ and `tab` query params and renders the `list` partial plus out-of-band chrome
+ (`backend/internal/web/web.go:348-360`; routes at `web.go:141-145`). The
+ tab URLs are built centrally so the param can't drop off one link
+ (`web.go:88-101`).
+- The only sort today is server-side and fixed: `h.store.List` returns
+ "already ordered updated_at DESC" (`web.go:293`).
+- Card mutations re-render **one card fragment** (`saveAndRenderCard`), and
+ delete answers an empty body that htmx swaps into the card's place
+ (`web.go:551-560`); chrome regions ride along as OOB partials
+ (`writeChromeOOB` `web.go:371-383`, emitting `hx-swap-oob="true"` at
+ `chrome.html:10,35,78`).
+- Admin routes are owner-gated in one place (`requireOwner` at
+ `backend/internal/web/admin.go:107-117`; route list `admin.go:86-89`).
+ The Lanes fragment renders only itself for its own poll
+ (`uiLanes` → `lanes` partial, `admin.go:136-138`).
+
+### 2.4 htmx attribute inventory in the templates
+
+| Attributes | Where | Purpose |
+|---|---|---|
+| `hx-get` + `hx-target="#list"` + `hx-swap="innerHTML"` + `hx-push-url` + `hx-on::after-request` | `app.html:55-56, 62-63, 68-69, 72-73, 76-77` | Tab switches: swap the list, push the tab's URL, re-mark the active tab |
+| `hx-post` / `hx-delete` + `hx-target="[id='card-{{.Key}}']"` + `hx-swap="outerHTML"` + `hx-indicator` + `hx-disabled-elt` (+ `hx-vals`) | `card.html:49-51, 68-70, 89-91, 112-114, 124-126, 135-137` | Per-card actions that re-render only that card |
+| `hx-swap-oob="true"` | `chrome.html:10, 35, 78` | Recent strip, action key, Updated badge updated out-of-band with every list response |
+| `hx-get` + `hx-trigger="every 30s"` + `hx-swap="outerHTML"` | `lanes.html:11` | Self-replacing poll fragment |
+| `hx-post` + `hx-target="#readers"` + `hx-swap="outerHTML"` + `hx-confirm` | `readers.html:30-31, 38-39` | Whole-roster re-render after confirm-gated actions |
+| `hx-post` + `hx-target="#setup"` + `hx-swap="outerHTML"` + `hx-confirm` | `setup.html:27-29` | Confirm-gated token rotation |
+
+---
+
+## 3. The six needs against the official htmx 2.x docs
+
+### 3.1 URL-addressable filter/sort state that survives a reload and can be bookmarked
+
+**Covered idiomatically — and already proven in-tree.**
+
+- `hx-push-url` "allows you to push a URL into the browser location history.
+ This creates a new history entry" and "snapshots the current DOM and saves it
+ into its history cache" (`https://htmx.org/attributes/hx-push-url/`; values
+ `true`, `false`, or an explicit URL; inherited).
+- `hx-replace-url` does the same without a new history entry
+ (`https://htmx.org/attributes/hx-replace-url/`).
+- Interaction with `hx-get` swaps — docs, History Support
+ (`https://htmx.org/docs/#history`): "htmx will snapshot the current DOM and
+ store it before it makes a request… It then does the swap and pushes a new
+ location onto the history stack." Back/forward restores from the cache; on a
+ cache miss htmx re-requests the URL with `HX-History-Restore-Request` and
+ "expects back the HTML needed for the entire page." The docs' **NOTE** is the
+ constraint that matters for the IA ticket: "If you push a URL into the
+ history, you **must** be able to navigate to that URL and get a full page
+ back!"
+- So the issue's premise is exactly right: *the swap itself never changes the
+ address bar*; only `hx-push-url`/`hx-replace-url` (or the `HX-Push-Url` /
+ `HX-Replace-Url` response headers, which "can override this attribute" —
+ `hx-push-url` docs, Notes) do. Server-rendered filtering keeps URLs
+ bookmarkable **if** the request URL carries the params and **if** the pushed
+ URL renders a full page — which the repo already does for tabs: the tab
+ links are real `` full-page URLs, and the same URL is used for the
+ swap (`hx-get`) and the push (`hx-push-url`) (`app.html:53-56`), with the
+ server rendering both the page and the fragment from the same params
+ (`web.go:88-101`, `web.go:348-349`). A filter/sort/paging control is the same
+ pattern with more params.
+
+### 3.2 A search box that queries as you type, without a request per keystroke
+
+**Covered idiomatically, zero JS — but it is a different mechanism from the
+current client-side filter.**
+
+- `hx-trigger` modifiers (`https://htmx.org/attributes/hx-trigger/`):
+ `changed` — "only issue a request if the value of the element has changed"
+ (distinct from the `change` event); `delay:` — "wait the given
+ amount of time before issuing the request. If the event triggers again, the
+ countdown is reset"; plus `throttle` for the drop-instead-of-reset variant.
+- The docs' own examples: "Active Search" (`https://htmx.org/docs/#trigger-modifiers`):
+ ``;
+ the same pattern on the attribute page with `input changed delay:1s`
+ (`https://htmx.org/attributes/hx-trigger/`); and the full example with
+ `hx-trigger="input changed delay:500ms, keyup[key=='Enter'], load"`
+ (`https://htmx.org/examples/active-search/`).
+- Parameters: "an element that causes a request will include its value if it
+ has one" (`https://htmx.org/docs/#parameters`); `hx-include` covers other
+ elements (same section).
+- `[INFERENCE]` Trade-off to hand to the consuming ticket: debounced htmx
+ search is **server-driven** — one request per settled query, which is what
+ makes the query URL-addressable — whereas the current `filter.js` filter is
+ instant, offline, and invisible to the URL. The docs' progressive-enhancement
+ section notes the active-search pattern does not degrade without JS unless
+ wrapped in a real form (`https://htmx.org/docs/#progressive_enhancement`).
+ The two designs are alternatives, not complements.
+
+### 3.3 Paging — and whether "load more" and true pagination differ
+
+**No pagination attribute exists; both styles are compositions of the same
+primitives. The difference is swap style and URL semantics, not features.**
+
+- "Load more": the docs' only paging example is Infinite Scroll
+ (`https://htmx.org/examples/infinite-scroll/`): a sentinel row
+ `hx-get="/contacts/?page=2" hx-trigger="revealed" hx-swap="afterend"`,
+ where `revealed` "fires once when an element first scrolls into the
+ viewport" (`https://htmx.org/docs/#special-events`; `intersect once` is the
+ documented alternative for scrollable containers). A "Load more" button is
+ the same idea with a click trigger and `hx-swap="beforeend"` — "Insert the
+ response after the last child of the target element"
+ (`https://htmx.org/attributes/hx-swap/`).
+- True pagination (numbered pages): each page link is an `hx-get` on that
+ page's URL with `hx-target` on the list, `hx-swap="innerHTML"`/`"outerHTML"`
+ to *replace* the list, and `hx-push-url` for the address — i.e. exactly the
+ repo's tab pattern (`app.html:55-56`). `[INFERENCE]` no docs page documents
+ numbered pagination as such; it is the same building blocks with a replace
+ swap and a pushed URL.
+- `[INFERENCE]` practical difference for the consuming ticket: load-more
+ appends (state lives in the DOM, page numbers do not appear in the URL, the
+ filter.js re-apply hook at `filter.js:48` still runs after each append swap);
+ numbered paging replaces and is bookmarkable per page. Both are available in
+ the vendored version (§4).
+
+### 3.4 A per-row action that re-renders only that row
+
+**Covered idiomatically — this is the oldest pattern in the repo.**
+
+- `hx-target` "allows you to target a different element for swapping than the
+ one issuing the AJAX request" — any CSS selector, or `this`/`closest`/`find`/
+ `next`/`previous` (`https://htmx.org/attributes/hx-target/`; inherited).
+- `hx-swap="outerHTML"` — "Replace the entire target element with the
+ response" (`https://htmx.org/attributes/hx-swap/`).
+- Out-of-band updates: content in a response carrying `hx-swap-oob="true"` is
+ swapped into the matching id elsewhere — "piggyback updates to other elements
+ on a response"; values `true` (= `outerHTML`), any `hx-swap` value, or
+ `swap:selector` (`https://htmx.org/attributes/hx-swap-oob/`).
+- In-tree proof: `card.html:49-51` targets the one card and replaces it with
+ the re-rendered card; delete answers empty and the card is removed
+ (`web.go:551-560`); chrome regions update OOB (`chrome.html:10,35,78`,
+ `web.go:371-383`). A Series-list row action on the admin page is the same
+ shape — target the row, `outerHTML` the response, OOB anything outside.
+
+### 3.5 A self-refreshing block on the same page as a filtered list
+
+**Covered idiomatically; the in-tree Lanes pattern is already the scoped
+variant, and the client-filter re-apply hook already exists for the un-scoped
+one.**
+
+- Polling: `hx-trigger="every "` polls the URL on an interval
+ (`https://htmx.org/attributes/hx-trigger/#polling` and
+ `https://htmx.org/docs/#polling`); a server response code **286** cancels the
+ polling (docs, Polling); a filter expression can gate it:
+ `hx-trigger="every 1s [someConditional]"` (hx-trigger page, Polling).
+- Keeping filter state — two documented/in-tree mechanisms:
+ 1. **Scope the poll to its own block.** The refresh replaces only the
+ polling element, so a filtered list in a sibling block is untouched. The
+ admin page already does exactly this: `lanes.html:11` polls and replaces
+ itself (`hx-swap="outerHTML"`), and the endpoint renders only the `lanes`
+ partial (`admin.go:136-138`). The readers roster — and any Series list —
+ live in separate sections.
+ 2. **If the filtered list itself is the polled block**, a client-side
+ filter would be discarded by the swap unless re-applied — and the repo
+ already re-applies on `htmx:afterSwap` (`filter.js:47-48`). If the filter
+ state is instead in the URL (server-rendered, §3.1), the polled URL
+ carries it and the server re-renders the same view.
+- Verdict: no extension, no hand-written JS required for either arrangement.
+
+### 3.6 Confirm-gating a row action
+
+**Both options already in-tree; the choice is UX, not capability.**
+
+- `hx-confirm` "allows you to confirm an action before issuing a request…
+ uses the browser's `window.confirm` by default", is inherited, and is
+ customizable through the event detail (`issueRequest` callback, `question`)
+ (`https://htmx.org/attributes/hx-confirm/`). Repo use: `readers.html:31,39`,
+ `setup.html:29`.
+- Server-rendered confirm row: the repo's other pattern — inline
+ `confirm-row` disclosures in the card (`card.html:106-140`) opened by
+ `toggleConfirmRow` (`filter.js:102-118`). This is hand-written JS
+ (hidden-attribute disclosure + focus management), not an htmx feature; it
+ exists because it is a richer in-page confirm than `window.confirm`. If a
+ Series-list row wants the same richer pattern, it reuses this code; if a
+ native confirm is enough, `hx-confirm` is zero JS.
+
+---
+
+## 4. Version check — what vendored 2.0.4 actually supports
+
+Not assumed from today's docs; checked three ways, all primary:
+
+1. **Direct grep of the vendored file** (`backend/internal/web/static/htmx.min.js`):
+ the attribute strings `hx-push-url`, `hx-replace-url`, `hx-trigger`,
+ `hx-target`, `hx-swap`, `hx-swap-oob`, `hx-confirm`, `hx-indicator`,
+ `hx-disabled-elt`, `hx-vals`, `hx-include`, `hx-on` are all present; the
+ swap styles `outerHTML`, `innerHTML`, `beforeend`, `afterbegin`, `delete`,
+ `none`; the trigger modifiers `changed`, `delay`, `every`, `throttle`,
+ `once`, `from`; the verbs `get`, `post`, `put`, `patch`, `delete`. The
+ `hx-on:` prefix matcher in the build also covers the repo's
+ `hx-on::after-request` spelling (`app.html:56`).
+2. **Release chronology**: v2.0.4 was released 2024-12-13
+ (`https://github.com/bigskysoftware/htmx/releases/tag/v2.0.4`); the release
+ bodies for v2.0.5, v2.0.6, v2.0.7 and v2.0.9 (checked via the GitHub API
+ 2026-08-17) contain no attribute additions or changes relevant to anything
+ cited here — docs, examples and bugfixes only. Nothing cited in §3 was
+ added after 2.0.4.
+3. **1.x → 2.x migration guide** (`https://htmx.org/migration-guide-htmx-1/`):
+ none of the cited attributes was removed in 2.x. The only 2.x syntax change
+ touching this repo is `hx-on` → `hx-on:` kebab-case event syntax, which the
+ repo already uses. The guide also states all **extensions were removed from
+ core** and are distributed separately — relevant only if an extension were
+ ever wanted (§5).
+
+Every attribute cited in §3 is therefore supported by the vendored 2.0.4.
+
+---
+
+## 5. Dependencies the work would imply
+
+**None.** Every need in §3 is met by the vendored htmx 2.0.4 plus the in-tree
+filter.js. No new Go module, no new npm package, no htmx extension download.
+
+- What a server-rendered filter/sort/page would cost is *server work*, not
+ dependencies: the list endpoint would read `q`/`sort`/`page` query params and
+ render filtered partials — the same mechanism `uiList` already uses for
+ `lib`/`tab` (`web.go:348-349`).
+- The one documented htmx extension that mentions sorting is `sortable`
+ (`https://htmx.org/examples/sortable/`) — drag-and-drop *reordering* via the
+ separate `sortable` extension wrapping SortableJS (a third-party library).
+ It is a different feature from column/field sort, and it is the only place a
+ new dependency could creep in. `[INFERENCE]` column sort needs it not: a sort
+ header is just another `hx-get` + `hx-push-url` control (§3.1).
+- If the IA ticket instead wants the *client-side* filter kept but made
+ URL-addressable (filter.js + `history.pushState` of the query), that is
+ hand-written JS — htmx pushes the **request** URL only, never arbitrary
+ client DOM state — and no dependency either way. `[INFERENCE]`
+
+---
+
+## 6. Sources
+
+- In-tree: `backend/internal/web/static/htmx.min.js`, `backend/internal/web/static/filter.js`,
+ `backend/internal/web/templates/{app,card,chrome,lanes,list,readers,setup,admin}.html`,
+ `backend/internal/web/web.go`, `backend/internal/web/admin.go` (all as cited above).
+- `https://htmx.org/docs/` — triggers/modifiers (§Triggering Requests, §Trigger Modifiers),
+ Active Search, Polling, OOB Swaps, History Support, Parameters, Progressive Enhancement.
+- `https://htmx.org/attributes/hx-push-url/`, `…/hx-replace-url/`, `…/hx-trigger/`,
+ `…/hx-target/`, `…/hx-swap/`, `…/hx-swap-oob/`, `…/hx-confirm/`.
+- `https://htmx.org/examples/active-search/`, `…/infinite-scroll/`, `…/sortable/`.
+- `https://htmx.org/migration-guide-htmx-1/`.
+- `https://github.com/bigskysoftware/htmx/releases/tag/v2.0.4` and the v2.0.5–v2.0.9
+ release bodies via `api.github.com/repos/bigskysoftware/htmx/releases`.