The Devices page's Pending Device Registrations section (Approve/Reject
cards broadcast to every account) is replaced by an always-visible Pair
a Device field: type the 6-digit code shown on the registering e-reader,
a templ-rendered confirm card shows what will be bound, and confirming
claims the device to the signed-in account.
Rendering ownership (AGENTS.md): the confirm card and every lookup
outcome are templ partials (PairingLookupResult) swapped by htmx from a
new SSR route POST /devices/pairing/lookup — the server decides what UI
looks like, no JSON-built HTML. Errors render as content because htmx
only swaps 2xx bodies (the route is web-only; the JSON API keeps real
status codes), and one delegated htmx:responseError listener in
device-management.ts toasts the non-swap failure path.
The claim confirm button posts straight to /api/devices/claim; success
comes back as HX-Refresh (same pattern as token regeneration), so the
page reloads into the paired state. No new TS module is needed — plain
htmx attributes drive the whole flow.
Ride-alongs: the Add New Device modal loses the device-type select and
the kobo manual form (its post-register UX depended on the broadcast
list and silently produced an unclaimable registration; native Kobo
sync is still server-side-only and will get its own pairing UX when it
lands) — the modal is now a single Connect-a-KOReader-Device guide with
the pairing steps. PendingRegistrationData, convertPending, and the
pending plumbing in the page handler are gone with the section.
ui-controls.md glossary and tests/ui COVERAGE.md updated in the same
commit as the control rename, per the drift gates.
User report: the Purge Archived dialog ('Permanently delete all
archived items?...') opened, and Cancel did nothing — and Confirm
would have been equally dead, leaving the page stuck behind the
overlay until a reload.
Root cause: confirm.ts wired the modal's buttons ONLY inside the
htmx:confirm interceptor. confirmDialog() — the promise API used by
purgeArchivedItems, remove-account, and other TS call sites — never
wired them. So whenever a page's first confirm interaction came from
the promise path (no hx-confirm element clicked first), both buttons
had no listeners. The 2026-10-30 confirm_probe spec only exercised the
hx-confirm path, which is why it survived.
Fix: wireButtons() now runs at module init (the header renders before
deferred modules) and inside confirmDialog() — both idempotent via the
existing data-wired guard.
Pinned by tests/ui/promise_confirm_probe.spec.ts (no env gating): the
purge POST is route-intercepted, so the probe asserts Cancel closes
the modal and Confirm issues the guarded request without deleting any
dev data. hx-confirm path re-pinned green (confirm_probe with BH_FEED).
Verified manually at 390x844: open -> cancelWired=true -> Cancel
closes -> Confirm fires the intercepted request.
Six specs driving the user-reported controls the way a human does,
encoding the sweep rules COVERAGE.md now states: menus/modals assert
VISIBILITY (never DOM text — hidden menus' text is why the 2026-10-30
panel sweep certified dead controls), geometry asserts the z-ladder
via elementFromPoint and viewport containment via boundingBox, every
click must produce an observable outcome (PUT fired, class flipped,
chip text swapped) and zero new console problems — the silent
CSP-throw family (theme buttons, connective chip, invisible op menu)
now fails a test instead of surviving to a user's mouse.
Covers: theme picker (interpolation regression from eb57b720),
pencil-glyph vs card-body contract, builder field menu / op menu
(full non-empty vocabulary) / connective both directions, the
Smart→Episodes create-modal seed, the picker overlay's ladder and
height, and the chain-order select's persistence round-trip.
Fixtures self-create/self-delete under test-prefixed names; localStorage
token + selectedLibrary=__all__ are seeded because the API login
establishes only the cookie; run the folder with --workers=1 (the login
route rate-limits and all suites share testuser — a parallel worker's
login revokes another's mid-run).
Marks the sweep-candidate list as executed (every knob driven in a real
browser), preserves the four defect classes the sweep fixed so the next
agent does not re-learn them the hard way, and pins the panel-vs-
Playwright limitation (value assignment without change events) that any
future automated spec of the htmx-triggered controls must work around.
User report: Remove Feed still used the browser dialog. Headless-proven
root cause (tests/ui/confirm_probe.spec.ts): the htmx:confirm handler
read evt.detail.element, which htmx 2 does not provide (its detail is
{ elt, question, issueRequest, ... }) — the handler returned at
'if (!elt) return' BEFORE preventDefault, so every hx-confirm sitewide
fell through to htmx's native confirm. The acbcc61a migration only ever
worked for direct confirmDialog() callers; hx-confirm interception was
dead on arrival against htmx 2 and no test watched the JS side.
Fix: read detail.question (htmx's own closest-attribute resolution of
hx-confirm); return without preventing default when absent. Probe
spec pins: click Remove Feed → #confirm-modal opens with the message,
cancel hides it, no dialog event fires.
Companion fixes in the same user-test round (one commit, one defect
family — templ boolean attributes + confirm-modal placement):
- Add-modal Download order never revealed: #podcast-download-all was
nested in a wrapper div, so ':checked ~ .podcast-add-order' had no
sibling relationship. Input, label, and order row are now siblings.
- Order/auto-download dropdowns always DISPLAYED the last option
('Newest first'/'Newest') whatever was saved: templ selected={ bool }
emits selected="false" — presence selects, so every option was
selected and the browser honored the last. Swept to selected?={...}
across podcast_detail (14), book_detail_modals (10, incl. two
always-checked checkboxes), collection_modal (2, same). Saves were
correct all along; display only.
- Global confirm modal moved from SidebarSignIn (signed-out only!) into
Header — signed-in pages had no #confirm-modal, so confirmDialog
degraded to window.confirm even with a working interceptor.
Facet bar scale (user question): values now carry feed counts, sort
frequency-first, cap at 6 chips per group with a native <details>
'+N more' expander.
Probes: tests/ui/confirm_probe.spec.ts + add_order_probe.spec.ts
(live-server, BH_FEED-gated) — both pass against the dev instance.
cmd/server/tests/podcast_test.go — six suites, the shared-dev-DB
doctrine (test*-named libraries, cascading cleanup, no binary
fixtures):
- TestPodcastRLSSurfaces: THE AUDITOR (invariant 13) — non-subscriber
404 on feed detail + SSR page (existence leak), episodes absent from
generic media reads, admin escape, subscriber visibility, then the
hidden-library flip hiding an already-subscribed feed (the feed
policy's library-visibility join). Seeding runs as the FIRST ADMIN —
the seed identity has neither subscription nor users row, so podcast
INSERT..RETURNING (SELECT policy under FORCE) 42501s for it;
production pods run as exactly that admin (invariant 12). All direct
row probes read through the admin scope for the same reason.
- TestPodcastAddFeedFlow: member add + subscribe + metadata-only
upsert; duplicate-URL dedupe (no second row); allow_member_feeds
gate (member 403 / admin 201 / subscribe-still-allowed).
- TestPodcastShrunkenFeedRetention: feed purges an episode from the
XML, both rows persist (upsert-without-delete pin, D14).
- TestPodcastEditSegregation: generic edit 400 + write-to-file refusal
for episodes; control book edit unaffected.
- TestPodcastDownloadWorker (ffmpeg-generated tone at runtime): file
lands at <download-root>/<feed-slug>/NNNN-…, audio_metadata probed;
re-download of a downloaded episode never re-fetches (idempotency
pin, enclosure-hit counted); dead enclosure → failed + persisted
reason + metadata intact. Fetch/SSRF tests swap the client via
SetPodcastHTTPClientForTests (the production client refuses
loopback httptest servers by design — the real client gets its own
negative test in services).
- TestPodcastScannerExclusion: SetFolders on a podcasts library root
yields zero scanner folders (invariant 4 pin) — MediaScanner
.FolderCount() accessor added.
Unit: internal/services/podcast_rss_test.go — namespace-aware parsing
fixture (three XML namespaces, fallback chains, hosts/guests),
duration spellings, RFC-822 variants, explicit variants, slug/path
layout, enclosure-extension selection, stripHTML, and the SSRF matrix
(private/loopback/link-local/multicast/mapped-v6 blocked; a loopback
httptest server PROVES the production client refuses it).
suites green: full integration run 161s / 0 failures; vitest 12 files
163 tests.
Contract widening: TestContractPin_MediaListBothBranches' raw-row
allowlist gains the 15 podcast episode columns (additive; documented in
docs/contract_pins.md — inert defaults on scan rows). Bruno: bruno/
podcasts/ mirrors the §8 endpoints (12 requests) + Bookhoard env vars.
tests/ui/COVERAGE.md rows 18-23: podcasts pages/lifecycle/RLS/chain —
Playwright sweep pending.
Phase 5.5 (plan §7.5) first increment: the SSR/HTMX frontend is swept
in a real browser (official Playwright image, no host browsers) for
BOTH personas — testuser (admin) and rls-phone (restricted; Audiobooks
hidden from them via the visibility API).
Green checks: auth rejection + session establishment; admin dashboard
renders sections incl. the hidden-from-others library; restricted
dashboard renders real sections (Recently Added present — not a
vacuous landing-page pass) with ZERO hidden-library traces; search and
media payloads leak nothing for the restricted persona.
Harness lessons encoded: / is a landing page (dashboard lives at
/dashboard); the login endpoint rate-limits rapid attempts so sessions
are established once per persona and reused via storageState — a sweep
must not look like a brute-force run; getByText visibility assertions
are wrong for <option> text (library switcher) — body-textContent
assertions instead.
COVERAGE.md tracks the full 14-area sweep per the plan; the remaining
areas (reader, collections CRUD, admin matrix, upload roundtrip) are
enumerated and pending their sweep runs.