Commit Graph
2213 Commits
Author SHA1 Message Date
John O'Keefe 39b0ab03fe docs(admin): users page — accounts, roles, last-admin guards
Eighth admin page. Documents the hidden-by-default create form (and why:
admin creation stays available with registration disabled — the form's
own design comment), live password requirements from active settings,
the card controls with their guards (last admin cannot be demoted or
deleted, dropdowns/buttons disable themselves with explanations), the
admin Edit User Profile modal (profile fields + role + Change Password
without current password — the admin-only difference), and delete
semantics (account + everything owned: devices, sessions, reading
data). Closes the loop on roles: admins get the Administration section;
users see only libraries visible to them, linking libraries.md for the
visibility story. Glossary gains + Create User and Edit User Profile.
(Settings referenced in prose only until that page lands — the first
amend of this commit briefly carried the same forward-link mistake the
dashboard page's amend fixed; pipefail is now the rule.)
2026-11-02 14:13:23 -05:00
John O'Keefe 535847477e docs(admin): all-podcasts page — the maintenance view
Seventh admin page. Documents the disk-usage sort (hogs surface first),
the header totals, each column's meaning (orphaned = no subscribers,
usually a departed user's feed), and the three actions with their real
semantics: Refresh (manual poll vs. the Settings→Podcasts interval),
Delete files (resets episodes to pending, re-download on demand, and
the non-obvious part quoted: a refresh will not re-materialize them),
Remove (instance-wide: rows + files + everyone's subscriptions, unlike
a user's own unsubscribe). Cross-links the user podcast guide for the
listening side. Glossary gains Delete files and a scoped Remove.
2026-11-02 14:12:46 -05:00
John O'Keefe aee1aafc74 docs(admin): hash conflicts page — the duplicate-content queue
Sixth admin page. Documents what creates conflict groups (scanner at
scan time + the hash backfill retroactively), the group card anatomy
(count badge, library + short SHA-256, per-copy rows with usage summary
highlighted when that copy holds reading data), and both resolutions
with verified semantics: Keep this copy (reparents the other copies'
children into the kept copy, deletes their catalog rows, records the
resolver — files on disk never touched, consistent with platform-wide
delete semantics) and Keep both (mark resolved, stop flagging, nothing
merged). Glossary gains Keep both and Keep this copy.
2026-11-02 14:12:10 -05:00
John O'Keefe 3e55ddd88a docs(admin): libraries page — the definition surface, with backlinks
Fifth admin page and the section's hub. Documents library creation
(name/description/type — type fixed at creation, verified in the edit
modal), the card's Force Rescan + Manage controls, and the full panel:
folders (add via Browse or manual path, shallow per-folder scan,
removal-unregisters-only semantics verified in DeleteLibraryFolder —
files and items untouched, archive lifecycle governs the rest; podcast
roots refuse hand scans), User Access visibility checkboxes (default-
allow, DB-enforced), the processing-issues link, and Delete Library's
exact destructive scope (records + reading data gone, files on disk
untouched). Ends with the at-a-glance scan table linking scanning.md.
Adds the Libraries backlinks to the processing-issues and archived
pages. Glossary gains Create Library, Manage, Edit Details, Delete
Library, View Archived, Purge Archived Now, Browse, User Access.
2026-11-02 14:11:25 -05:00
John O'Keefe ecb6a7385a docs(admin): archived items page — the missing-file lifecycle
Fourth admin page. Documents the two-consecutive-scans archive rule,
the history-preserving rationale, ARCHIVE_RETENTION_DAYS (default 90,
0 = manual-purge-only, verified in config.go), and the four actions with
verified semantics: Merge (progress/annotations/collections folded into
the active copy, per-user collisions keep the active copy — mirrors
hash-conflict resolution), Restore (immediate; re-archives if the file
is truly still gone), Delete (item + history, permanent), Purge All
Archived (the clean-slate button, confirm text quoted). Links to
scanning.md for why only library-wide scans archive. Glossary gains
Purge All Archived, Merge, and a scoped generic Delete entry.
2026-11-02 14:10:16 -05:00
John O'Keefe 88e3de606c docs(admin): processing issues page — per-library scan-failure ledger
Third admin page. Documents the severity stat cards (errors/warnings/
info, cards only appear when non-zero), the per-issue card fields, and
the three actions with their real semantics: Retry (per-item scanner
re-run — success closes the issue, failure surfaces the real error),
Retry all (sequential queue with done/total progress, deliberately not
a library-wide scan), Dismiss (warnings/info only, confirm-guarded;
errors cannot be dismissed). Links to scanning.md for the retry-vs-
library-scan distinction. Glossary gains Retry, Retry all, Dismiss.
2026-11-02 14:09:34 -05:00
John O'Keefe b3a8cbd582 fix(admin): library row button renamed 'Scan' -> 'Force Rescan', per owner
Owner ruling that closed the scan-semantics discussion: the code is
correct (per-library scan IS a force scan — hx-vals force:true since
706be09d), but the label hid that. Now the naming invariant holds
everywhere: anything with Force/Rescan in its name re-reads everything
in its scope; plain 'Scan' only exists at folder scope ('Scan this
folder', shallow) and in auto mode. Book-level Rescan verified as the
per-item force path (extractMetadata re-reads sidecars/tags regardless
of file size; recomputeHashInfo follows) — it is the documented answer
to 'I changed this book's sidecar'.

Glossary Scan entry replaced with Force Rescan (old label kept in also:),
scanning.md table + prose pick up the new label. Generated file
regenerated with the Dockerfile-pinned templ version from inside
templates/ so the diff is exactly the label strings.
2026-11-02 14:08:19 -05:00
John O'Keefe ed550fa914 docs(admin): dashboard page — stats, watcher, Quick Actions, scan panel
Second page of the admin tree. Documents the four-card stats grid with
what each number actually counts (instance-wide totals; Books = every
media item including podcast episodes), the File Watcher card's data
source (/api/scanner/watch/status, armed by Auto-Scan) and its
zero-libraries meaning, Force Rescan All Libraries with its real
mechanics (one job per library, skip-on-active, backfill tool vs.
routine shallow refresh), and the Scanning panel's live progress +
Cancel all scans. Links only to pages that exist (scanning.md); the
Settings reference stays prose until that page lands.

The real-tree nav test's Administration assertion loosens further: guide
leads, everything else strictly follows usabilityOrder — durable across
the remaining page landings. Also fixes the amend this commit replaces:
its first version briefly carried a forward link to the unwritten
settings.md (caught by the link gate, now removed) — the gate worked;
the && chain around go test let the bad commit through, which is exactly
why this amend exists.
2026-11-02 13:49:23 -05:00
John O'Keefe 89fc479bdb docs(admin): scanning page — the shallow vs. force taxonomy from source
First page of the admin tree, written to the new admin tier (what the
click actually does). Documents every scan surface with its verified
semantics: Force Rescan All Libraries (dashboard, force, all libraries,
one job each with skip-on-active), the per-library Scan button (ALSO a
force scan — hx-vals force:true since the button's birth commit 706be09d,
which corrects the owner's stated 'other scan buttons are shallow'
belief; flagged to the owner), Scan this folder (shallow, single-folder
scope, archive pass skipped for partial scans, podcast roots refused),
Auto-Scan + Scan Interval (the watcher + poll + startup triggers, all
shallow, default 60s range 1-3600), Processing Issues retry (per-item
sequential, deliberately not a library scan), and book-level Rescan /
Reset to Scanned. Plus the standing rules: one scan per library
(coalescing), background jobs with the dashboard progress panel, and
the two-consecutive-scans archive rule.

Glossary gains Scan and Scan this folder (same-commit rule for controls
cited in prose). The real-tree nav test's Administration assertion now
pins shape (Admin Guide, Libraries prefix + usabilityOrder subsequence)
instead of an exact slice, so pages landing one at a time don't churn it.
2026-11-02 13:47:40 -05:00
John O'Keefe 20754f15e2 docs(standard): admin tier defined; promises require owner approval
Two additions to the screenshot-free writing standard, both owner-
ruled during the admin-docs planning session:

- Tone by audience gains the Administration tier, ending the 'not yet
  decided' limbo for admin docs: plain language like the user docs, plus
  what every control actually does (semantics, cost, consequences)
  before the reader clicks, and per-setting what/does/means/range
  coverage. Calibration in one line: user = what you see, admin = what
  it does, developer = how it works. The Developer section keeps its
  separate tone discussion for the deferred dev-docs pass.

- New 'Promises need approval' section: no coming-soon / under-
  consideration / future-release language anywhere without the owner's
  explicit approval of that exact promise; speculative text found in the
  docs is reported, never quietly kept or quietly deleted. Provenance
  recorded (the 2026-11-02 device-promise removals; the Operations
  placeholders knowingly kept).
2026-11-02 13:40:34 -05:00
John O'Keefe bb141cdffc fix(admin): rename 'Scan All Libraries' to 'Force Rescan All Libraries'
Owner: the dashboard button's label under-promised what it does. The
action is a FORCE rescan (scanner.SetForce(true) via the scan worker):
every file re-read and re-extracted with original-scan semantics, hash
identifiers recomputed — the admin's backfill tool, per the scanner's
own source comments. The shallow scans (per-library button, per-folder
scan, processing-issues retry) deliberately skip unchanged files and
keep their 'Scan' labels; the naming now carries that distinction.

Subtitle updated to state the real semantics ('Re-reads every file and
re-extracts metadata and hashes — slow but thorough') instead of the
old 'Re-scan existing files and detect new items', which described the
shallow path. ui-controls.md gains the canonical glossary entry (old
label recorded under also: so anything still quoting it resolves); no
doc quoted the old label yet, so no doc edits were needed.
2026-11-02 13:39:52 -05:00
John O'Keefe 393a00e8dd docs(nav): Administration section lives in docs/admin/; sidebar ordered by usability
Two structural changes, one owner-approved plan:

- Move the admin guides out of docs/user/ into their own docs/admin/
  area — admin-guide.md becomes the Administration section landing page
  (pinned first), and the 32-line admin-library-management.md stub
  becomes admin/libraries.md, which the admin-docs build-out rebuilds
  into the real Libraries page next. Retired URLs 301 via a new
  docRedirects map in the docs HTTP handler (checked before .md
  canonicalization so even .md spellings one-hop to the new clean URL);
  the two inbound links (docs index, user-guide front door) updated.

- Sidebar direct items now sort by usabilityOrder — an explicit,
  per-section display order — instead of alphabetically: User Guide
  mirrors the 'Where to go' table on user-guide.md, Devices orders
  Android → KOReader → Kobo (alphabetically Kobo used to sort ahead of
  KOReader), and Administration pre-lists its planned pages so each
  lands in place as the admin build writes it. Unlisted docs fall back
  to alphabetical after the listed ones. Display-order only by
  construction: the URL-driven expand/highlight machinery keys on
  containsURL membership and never sees sort order — nav tests updated
  to pin the new orders plus a real-tree test
  (TestBuildNavigationUsabilityOrder) guarding all three sections.
2026-11-02 13:38:01 -05:00
John O'Keefe 7e2e1c4c60 docs: remove speculative promise language, de-brand Android design analogy
Owner rulings from the admin-docs planning session:

- sync-guide: dropped 'Kindle and reMarkable are under consideration' —
  no such plans exist, and promising support for proprietary device
  interfaces we will never build sets false expectations. Device posture
  stays as the table states it: web, Android app, KOReader anywhere
  (including jailbroken Kindle hardware), native Kobo the sole exception.
- calibre-integration: cut 'Custom Calibre Columns (Future)' + 'Stay
  tuned!' — unshipped-feature promises do not belong in docs; calibre
  integration is a by-product of the scanner's sidecar support, not a
  roadmap item. The features list describing Calibre itself stays (that
  is Calibre's promise, not ours).
- android-app: de-branded the two Kindle references per owner's legal
  caution — 'the Kindle app to Kindle cloud' analogy and 'quality bar is
  the Kindle app' are now 'any dedicated reader app to its server' / 'a
  first-class native reader app'; engineering substance unchanged.

Governance rule going forward (codified in writing-ui-docs.md in a
follow-up): no future-improvement or coming-soon language anywhere
without explicit owner approval; anything found gets reported, not
silently kept or silently removed.
2026-11-02 13:34:21 -05:00
John O'Keefe 2165829d0e fix(ux): confirm modal's Cancel/Confirm were dead on the promise-API path
Release / build-and-push (push) Successful in 3m29s
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.
v2.4.4
2026-11-02 11:24:55 -05:00
John O'Keefe 04e855e691 fix(ui): right slide-out sheet for oversized modals on phones; 360px fixes
Auditing at 360x760 (Firefox's smallest emulated device; Galaxy S25
class) — 390 was too generous a baseline — the Customize Dashboard
pop-up was the worst page in the app: its card measured 986px in a
760px viewport with no internal scroll, so Cancel/Save sat ~90px below
the fold on a fixed, non-scrolling overlay — unreachable.

New reusable shell: .app-modal-panel (input.css) renders a centered
dialog on desktop (>=640px, unchanged) and a full-height right
slide-out sheet on phones: h-dvh, rounded-l-2xl, slide-in animation
(replays on every open because the overlay toggles display), internal
scroll, overscroll containment. Overlay pairs with items-stretch /
justify-end below sm.

Converted the measured overflows:
- Customize Dashboard (986px): sheet + sticky header/footer, rows wrap
  with the Restore/toggle group right-aligned (ml-auto), titles
  truncate, System badge shrink-0.
- Collection create/edit (796px, cut at both ends at 360): sheet +
  sticky header/footer; edit modal shared the file.

Also at 360: admin-library's Archived buttons row (View Archived +
Purge Archived Now, 315px at x=49) inflated the page to 364px — now
flex-wrap. Collection-episodes shows a 1px sub-pixel rounding artifact
(vw=361) — left as-is, invisible.

Verified at 360x760: sheet geometry (footer bottom 739 < 760), rows
fit, desktop 1280 screenshot unchanged; full-route sweep at 360 shows
no page-level horizontal overflow beyond the two notes above.
2026-11-02 10:55:23 -05:00
John O'Keefe 3e8ac3b9b7 docs(user): koreader install is a git clone (git pull to update), per owner
Replaced the 'clone then copy to plugins/' phrasing with the actual
workflow: clone straight into the plugins folder (koreader/plugins,
.adds/koreader/plugins on Kobo), restart KOReader; updates are git pull
in bookhoard.koplugin + restart. Closes the last prose-trusted item in
the user-docs rebuild.
2026-11-02 10:42:49 -05:00
John O'Keefe 10a5ac88da docs(user): themes — drop the invented 'default light theme' tag, per owner
Lupine's '(default light theme)' tag had no basis anywhere: the string
exists only in the ThemeType union, and there is no light-default logic
in theme.ts or the Appearance panel. Platform-owner clarification:
Tokyo Night is the sole default (fresh install); the Dark/Light control
is a list filter with per-half counts, not a theme toggle. The page now
says exactly that, keeps the code-verified bits (mode filter opens on
your current theme's half via ThemeModeOf, checkmark indicator, counts)
and states the one-default rule plainly.
2026-11-02 10:39:09 -05:00
John O'Keefe 3feb608d26 docs(user): two more code-grounding fixes from the verification sweep
- sync-guide matching ladder was missing rung 8: Title only at 0.3
  confidence, last-resort after title+author+size (book_matching.go
  priorities 1-8, file-path priority handled at the API layer).
- library-browsing said the sort dropdown had 'more' options than it
  does: exactly five (Title A-Z/Z-A, Author A-Z, Date Added, Page
  Count).

Sweep results, all verified against source: themes.md 24+13=37 exact
(web/src/theme.ts union), reader-guide claims (corner zones, selection
copy, comics annotations disabled - bookmarks only) present in
web/src/reader/reader.ts, android-app.md strings (local-network
permission, login error table) in bookhoard-app source, calibre
dc:title/calibre:series parsing in media_scanner_calibre fixtures.
2026-11-02 10:28:45 -05:00
John O'Keefe 9b2d3da7e2 docs(user): koreader-setup — document the actual plugin menu from source
The guide documented setup and stopped. Everything after 'approved' was
undocumented: the real menu tree from bookhoard.koplugin main.lua is now
in the guide — one-tap actions (Push progress / Pull progress / Sync
rating, review & time now / Setup OPDS catalog / Device info /
Disconnect / Register device), Automatically push progress (DEFAULTS
OFF — the likely cause of 'sync not working after approval' reports),
Periodically sync every # pages (default 50), Sync mode
(Immediate/Checkpoint), Sync behavior (Sync to a newer state default
Prompt / to an older state default Never — the device-side side of
conflict handling), and the seven What to sync toggles (progress,
bookmarks, highlights, notes, ratings, review, reading time — all on by
default; the ratings/review/reading-time sync was entirely missing from
docs despite the six server endpoints).

Also fixed: Android 'coming later' line now points at the real app,
OPDS auto-registration verified in the approval poll (setupOPDS on
approved), expiry wording, and a troubleshooting entry for the
wifi-toggle permission message auto-sync can raise. Ground truth noted
in a source comment; verify against plugin source on plugin changes.
2026-11-02 10:23:52 -05:00
John O'Keefe 788e9463cf chore(web): delete dead linking module
The module could never execute against any real page:

- renderUnlinkedBooks targeted #unlinked-books-list, an id that exists
  in no template; the live /unlinked-books page is server-rendered by
  templates.UnlinkedBooks and driven by the Alpine unlinkedBooks
  component (web/src/unlinked_books.ts, setupEventDelegation) with its
  own CSP-safe wiring.
- showSuggestionsModal/hideMatchModal need #match-modal and
  #match-modal-content, which no template renders either.
- Its inline onclick handlers referenced window.showMatchModal, a
  function that was never defined anywhere.

Nothing imports it except main.ts's side-effect import, and no
template/Go/TS surface references its Alpine component registration,
containers, or globals (grepped templates/, internal/, web/src/).
Verified after removal: tsc, vp check, vitest (198), and /unlinked-books
plus dashboard/stats load with zero console errors, empty state and
Alpine component intact.
2026-11-02 10:23:43 -05:00
John O'Keefe ef4de7896a fix(csb): custom-section book picker search never worked — two latent bugs
Fine-toothed functional verification of the delegation work exposed that
the picker's book search has been dead since launch, for two
independent reasons:

- The builder fetched GET /api/books/search, a route no router has ever
  registered — every search 404'd (the CSP-dead onclick buttons on the
  results hid the failure). It now calls the unified JSON search the
  bookshelf uses (/api/media-items/search?q=&library_id=), maps its
  rows (id/title/author/cover_image_path) to the picker's BookInfo
  shape, and treats the endpoint's 404-with-results:[] as empty.

- Even with results, the picker container shared id=search-results with
  the header search dropdown (header.templ renders it on every page);
  getElementById returns the first match in tree order, so results were
  written into the invisible topbar dropdown. The picker container is
  now cs-search-results (templ + all three TS references).

Verified end-to-end at 390x844: select library -> search '1984' -> 3
results render -> click adds the selection chip -> chip x removes it.
Zero console errors.
2026-11-02 10:14:35 -05:00
John O'Keefe 12aab156f6 docs(user): sync-guide rebuild to today's platform; calibre guide de-API'd
sync-guide.md was 591 lines with good implementation-derived bones
(matching confidence table, universal locations, conflict flow) wrapped
around a stale device story: Android 'coming later' (the app exists and
self-registers), a deleted queue UI, invented UI (color status badges,
Auto-Resolve Future, Priority Sync buttons), placeholder support links,
an HTTPS-1.3 overclaim on LAN setups that are plain HTTP by design, and
a changelog promising the future. Rebuilt to ~140 lines, every claim
grounded: device table matches reality (web/Android/KOReader supported,
Kobo native server-complete with rollout pending), resolution
strategies as the API actually defines them (device / server /
highest_progress), offline queuing honestly scoped (Android queues;
server holds per-device queue; no dedicated queue UI today), reading
history on Progress page, RLS-era privacy wording, device limit 10
default admin-settable. Reader Sync Progress button verified.

calibre-integration.md: mostly current (sidecar-first model, Dublin
Core tables, Write-to-file section all kept); fixed the user-hostile
'POST /api/scanner/scan' instructions to point at the admin scan
controls, and dropped the yourusername placeholder repo links.
2026-11-02 10:12:41 -05:00
John O'Keefe c1a7648076 docs(user): reader audio section + kobo guide refresh to platform reality
reader-guide.md gains the audiobook/episode player section (seek,
prev/next episode, Bookmarks panel, Chapters list, Player settings with
autoplay/speed/resume-rewind overrides, Shortcuts dialog) — the player
existed with zero user docs.

kobo-setup.md drops the stale 'not available yet, actively being
developed' framing: the server side of native Kobo sync is implemented
and documented (sync/initialization endpoints, bookmark + markup sync,
shelf mappings, KEPUB sidecar config), so the guide now states exactly
that, keeps stock-firmware rollout as the honest remaining piece, and
keeps KOReader as the supported today-path.

ui-controls.md: Player settings / Shortcuts & interactions / Chapters /
Bookmarks entries (templ-entity fragments for ampersand labels).
2026-11-02 10:09:18 -05:00
John O'Keefe 9e2f216af5 docs(user): dashboard, browsing, profile rewrites + new podcasts guide; trash stubs
Ground-truthed against the templates this pass:
- dashboard.md: real section kinds (system shelves incl. Continue
  Series, user collections, custom sections via /custom-section
  builder), Customize Dashboard panel semantics (per-library, drag,
  switches, 10-50 items, Save Changes), Refresh, This Week widget,
  per-item Read/Listen and progress-conflict resolve actions. The old
  page listed a wrong shelf set and never mentioned custom sections.
- library-browsing.md: the file was secretly titled 'Saving Custom
  Filters' and covered nothing else. Now the browsing page: All Books
  toolbar (search, Sort, Filters -> drawer hand-off, Save/Load/Clear
  saved filters), book detail as hub, series pages, podcasts split.
- profile-guide.md: field table ground-truthed from profile_form.templ
  (username/email DO remain editable there), audio defaults (speed,
  resume rewind, keep awake), timezone rendering contract, both theme
  doors (7-pick select vs 37-theme Appearance panel), Change Password
  with live checks, Danger Zone/Remove My Account + last-admin rule.
- podcasts.md NEW: the Podcasts page had zero documentation. Subscribing
  (Add Feed -> RSS URL, library, back-catalog download, download order,
  Add & Subscribe), Continue/All Feeds, Download All, player + Play as
  Sequence cross-link.
- Trashed user-areas.md, frontend-guide.md, settings-guide.md: stubs
  whose planned content now exists as real pages (or duplicates
  profile-guide). user-guide.md rewritten as the plain-language front
  door with a go-here-when table; index.md rows updated to match.
- ui-controls.md: 10 new gate-checked entries for every newly cited
  control (Add & Subscribe via templ-entity fragment).
2026-11-02 10:07:28 -05:00
John O'Keefe ceed3ddeae fix(stats): Most Active card wrapped mid-token on narrow screens
'2026-11-01 (148 min)' broke as '(148 / min)' in the half-width stat
card. Date and minutes now render as stacked block lines — date,
minutes, weekday — so nothing splits across rows.
2026-11-02 09:46:58 -05:00
John O'Keefe 0673550d58 fix(admin): library rows collapse on phones — chips hid the name
Visual review of the first flex-wrap attempt: with the name block at
flex-1 (0 basis), the row never registered as overfull, so wrap never
fired and the shrink-0 chips still overprinted Scan/Manage. Dropping
flex-1 lets wrap trigger — but then the chips starved the name down to
'P…'. The type/folders chips are the least-important content in the
row: hidden below sm, so phones show icon + full name/description with
Scan/Manage on a second row; desktop keeps the single-line layout with
chips (>=640px unchanged).
2026-11-02 09:46:58 -05:00
John O'Keefe 71b171bf72 fix(csp): allow blob: stylesheets in style-src for the reader
Every /readers load logged a CSP violation: foliate-js injects its
reader-generated stylesheets as blob: URLs, and style-src only allowed
'self' 'unsafe-inline' — so the sheet was refused and selection/
highlight styling inside the reader rode on nothing.

blob: was already granted for img-src, font-src, and frame-src for
exactly this reader content; style-src now matches. The content is
app-generated at runtime (no cross-origin input), script-src is
untouched, and the strict script hash allow-list keeps its guarantee.

Pinned by TestThemeFlashGuardHashMatchesCSP (script-src only — the
style-src addition doesn't interact) and TestGoSourcesAreCSPSafe.
2026-11-02 09:31:58 -05:00
John O'Keefe 2df6f8e55c fix(ui): replace CSP-blocked inline handlers with delegated listeners
The hash-allow-list CSP refuses inline onclick=/onerror= attributes, so
three TS islands shipped dead controls:

- stats.ts rendered Most-Read/Longest covers with onerror= — every
  /stats load logged violations (5+ on the dev data) and the broken
  cover fallback never ran. Now: capture-phase error listeners on the
  three list containers (they persist across innerHTML swaps; error
  does not bubble but capture still reaches them).
- custom-section-builder.ts picker rows and selection chips used
  onclick= against window globals. Rows now carry data-book-id/title/
  author and chips data-remove-book; initCustomSectionBuilder wires one
  delegated click listener per container. window.addBookToSelection/
  removeBookFromSelection exports removed — no remaining references.
- linking.ts: the unlinked-books 'Link' button called
  window.showMatchModal, which never existed anywhere (doubly dead:
  missing global AND CSP-blocked); suggestion rows and Cancel used
  onclick= too. All now data-attribute driven through one document-level
  delegated listener; row Link opens the suggestions flow via
  getSuggestions(progressId), which fetches matches and opens the modal.

Verified: tsc --noEmit, vp check, vitest (198), manual modal flows.
2026-11-02 09:31:58 -05:00
John O'Keefe 88adf30aa9 fix(ui): mobile layout overflows across admin, reader, and book pages
Every fix is a minimal class-level change; desktop layouts are untouched
because each fixed row already fit on one line there.

- progress: CFI string is one unbreakable token — it inflated the page
  layout width to 398-501px (whole-page zoom-out on phones) and rendered
  debug-looking text edge to edge. min-w-0 + break-all lets it wrap.
- audio reader: settings strip min-content (~470px) exceeded the 390px
  viewport, clipping Skip/Sleep and — because the page ICB inflated —
  pushing the chapters drawer (w-full of the viewport) past the screen
  edge so its timestamps clipped too. flex-wrap fixes both symptoms.
- book detail edit-metadata modal footer: Rescan/Reset/Write-to-file row
  overflowed the modal; clipped buttons were unreachable. flex-wrap.
- bookshelf save-filter modal footer: three buttons overflowed both
  screen edges. flex-wrap.
- admin libraries: rows overprinted (badge chips shrink-0 vs actions
  shrink-0, no wrap) — 'manga⟳Scan▸1 folders⌄Manage'. flex-wrap stacks
  actions under the name on phones.
- admin podcasts: maintenance table forced a 892px page (44% zoom).
  overflow-x-auto on the card scrolls the table inside it instead.
- stats: 4-pill range row forced 421px; page permanently ~7% zoomed out.
  flex-wrap.
- podcast feed detail: label+select pairs now travel in their own flex
  pairs so flex-wrap can never split 'Order' from its select; ids and
  htmx change-triggers preserved.
- cosmetics: conflicts stat labels tracking-wide→normal (UNRESOLVED
  clipped), archived file path line-clamp-3, rules-page Smart badge
  wraps cleanly, dashboard View All shrink-0 nowrap (was wrapping to
  two lines).
2026-11-02 09:30:55 -05:00
John O'Keefe e3ca3426ba fix(auth): emit default_audio_speed unconditionally on the profile response
TestDocsMatchRoutes failed on users/get_profile.md: the example carries
default_audio_speed but the response struct tagged it omitempty, so the
gate's zero-value marshal never sees the key and reads the doc as
inventing it. The tag was semantically dead: the column is NOT NULL
DEFAULT 1.0 (schema.sql 44) and writes validate 0.25-4, so a stored 0
is out of domain and every real profile emits the field. Response-only
struct, no form-bind impact. The key was added to the doc example in
ef827a3a without a gate run - this is exactly the class of drift the
gate exists to catch; make test now passes again.
2026-11-02 08:56:37 -05:00
John O'Keefe 42b283480a docs: archive the screenshot-automation plan as rejected
The Playwright screenshot pipeline is not being built: screenshots are
the only doc format no gate can verify, they rot silently at every UI
change, and a redesign was the stated plan. Replaced by
docs/developer/writing-ui-docs.md (pattern-first text, exact quoted
labels enforced by TestUIControlLabels) — see that page for the full
reasoning. Body kept below unmodified for history.
2026-11-02 08:22:01 -05:00
John O'Keefe 913174cdf8 docs(gates): widen relative-link checker to all docs, repair the rot it finds
TestDocsRelativeLinks walked only docs/developer; widening it to all of
docs/ immediately surfaced the same class of rot the gate was built
for, outside its old reach:

- docs/index.md was the old monolith's landing page: linked the deleted
  api-reference.md, 10 directory-style area links, and escaped the docs
  tree to README/AGENTS/.env/compose. Rewritten as a portal whose every
  link resolves, with one representative endpoint doc per API area and
  pointers to the new writing-ui-docs.md standard.
- directory-style links in user stubs (devices/, ../operations/)
- case-mismatched links (Development.md) in contributing.md
- monolith-era pointer (API_REFERENCE.md, TROUBLESHOOTING.md) and
  escaped AGENTS.md links in contributing/development.md
- docs/development/calibre-opf-implementation.md double-up escapes, a
  link to the removed repo-root plan file (absorbed here), and a
  source-code hyperlink the docs site can't serve (now inline code)
- operations/operations.md linked three never-written guide pages as
  aspirational links - they stay plain text until pages exist

HANDS-OFF (project owner, 2026-11-02): docs/user/filters/** and
docs/user/searching-and-filtering.md are exempt as link sources so no
future checker failure can force edits to the filter documentation.
Files linking INTO them remain checked.
2026-11-02 08:21:47 -05:00
John O'Keefe bf4735f3ce docs(user): rewrite collections to the screenshot-free standard
The old page was stale in ways the standard now forbids: spatial
language ("top-right"), documented URL query params users should never
need, no mention of smart collections or podcast episodes, no hand-off
to the filter docs.

Ground-truthed against the templates: Static vs lightning-Smart type
choice, Books/Podcast episodes rules scope, Add Items picker ( Books /
Episodes tabs, Add Selected, Remove Selected), Edit Rules -> Filter
tree -> Apply to Collection, Play as Sequence with the listening-order
picker (Feed order / reversed / oldest / newest), Hide finished books
(reading-queue mode) and Show Up Next strip toggles, system shelves
(Continue Reading, Recently Added, Recently Read, Not Started, Continue
Series) and Restore System -> Restore.

Plain language per the user-docs tone contract: short sentences,
pattern-first (one shelf concept, four flavors), every step ends in an
outcome, rules taught by LINK to the filter guide (reference, never
restate). Every quoted control exists as a literal and is glossaried in
ui-controls.md (gate-checked).
2026-11-02 08:18:27 -05:00
John O'Keefe e07301f74a docs(standard): screenshot-free UI documentation standard + UI-label gate
The planned Playwright screenshot docs (SCREENSHOT_AUTOMATION.md) are
replaced by a text standard: screenshots rot silently and cannot be
gate-checked, while this UI's labels are server-rendered templ literals
that can. Layers: pattern-first prose (concepts survive redesigns) >
capability inventory > label->outcome flows > quoted labels > generated
references where a registry exists (filters/fields.md precedent). The
filter docs stay hands-off exemplars; user-doc tone is plain language by
contract, dev/admin tone explicitly deferred.

writing-ui-docs.md is the standard (page template, findability notes,
tone-by-audience, process rule: a UI change is not done until the docs
audit runs). ui-controls.md is the canonical control-label glossary the
docs quote from; TestUIControlLabels greps every entry against
templates/*.templ (source: ts override; also:-fragments for
Alpine-composed labels like the Match/Group header) so renames fail
make test in the same commit as the rename. Gate proven both ways: a
planted bogus label failed; restored set passes.
2026-11-02 08:18:19 -05:00
John O'Keefe fbdd4ab9f9 feat(audio-player): Resume Rewind select in the settings popover
Release / build-and-push (push) Successful in 4m43s
The player window could only change Skip and Speed; resume rewind
lived exclusively on the profile page. The user-facing settings now
match Speed's precedent — changeable from the player, saved to the
account, applied immediately.

The select mirrors the default-speed row (CSP-safe @change bound to
one Alpine method; templ emits the option list from the shared
ResumeRewindOptions allow-list and the :selected expression against
the live resumeRewind field). Applying updates the running player's
value first — the next open/unpause uses it with no reload — then
PUTs audio_resume_rewind_seconds to the profile endpoint as the
string wire shape the server binds. normalizeResumeRewind gates the
select's value through 0/5/10 before both steps.
v2.4.3
2026-11-01 21:29:19 -05:00
John O'Keefe ef827a3a05 test(api): pin the resume-rewind contract
Release / build-and-push (push) Successful in 3m2s
TestAudioResumeRewind drives the whole surface against the shared dev
DB with the fresh per-test regular user (the live admin is never
touched; the fixture book lives in a test-named library that cascades
away): default 0, profile PUT "5" rides the chapters payload,
non-allow-listed values 400 without side effects, explicit "0"
resets everywhere, and an omitted JSON key leaves the column
untouched.

Changelog entry in docs/contract_pins.md (additive chapters key +
the profile field's string-shaped PUT, keep_screen_awake precedent)
and the get_profile.md example gains the preference keys.

Debug note for the next agent: the first runs here 401'd with
'Ghost session rejected' — NOT a harness or auth bug. The tests never
execute schema.sql; the shared dev DB gets its schema from the app
container's boot. The rebuilt tests image ran the new GetUser
(selecting audio_resume_rewind_seconds) against a DB whose schema
came from the old app image — every GetUser errored, and the
ghost-session guard turned that into 401s. Applying the schema.sql
ALTER to the dev DB (what the next app rebuild does anyway) turned
the suite green. Until make rebuild-app runs, the running app
container serves the old binary.
v2.4.2
2026-11-01 19:38:08 -05:00
John O'Keefe 7b7ce39826 feat(audio-player): apply the resume-rewind setting on open and unpause
The chapters payload now carries resume_rewind_seconds (server-side
per-user setting); the player applies it at two events, each rewinding
once from where playback currently sits — never stacked:

- open-resume: restorePosition starts the rewind early from the saved
  spot (percentage and chapter+offset branches, clamped at the book
  start). Deep links (?loc=) stay exempt — an explicit jump is not a
  resume.
- unpause: a genuine pause→play steps back again (the re-orientation
  gap). Programmatic plays — post-restore autoplay and track-switch
  autoplays — set a suppress flag: their play event is the session
  start the open-rewind already positioned, not an unpause.

Math lives in audio-rewind.ts (pure, unit-tested) after the
audio-scrubber pattern; payload values gate through the 0/5/10
allow-list so stale or foreign servers degrade to exact resume.
2026-11-01 17:21:35 -05:00
John O'Keefe 9cf9e1d305 feat(api): expose and validate the audio resume rewind setting
GET/PUT /api/auth/profile now carries audio_resume_rewind_seconds
(always-present on the response: 0 is a real value, exact resume; the
request shape is the keep_screen_awake string precedent so the form
select can send an explicit 0=Off while JSON clients that omit it
leave the column untouched — a plain int with an omitempty contract
could not express 'set to off'). Strict allow-list {0,5,10}: the UI
offers exactly three choices and the API refuses everything else.

The audiobook chapters endpoint embeds resume_rewind_seconds in its
existing payload — it already loads the user row for playback speed,
so both players (web + Android, phone UI and the Android Auto service)
get the value with the fetch they perform anyway; zero extra round
trips.

Profile page gains the Resume Rewind select (Off / 5 s before /
10 s before) next to Default Playback Speed.
2026-11-01 17:19:58 -05:00
John O'Keefe daffe499eb feat(users): audio_resume_rewind_seconds column
Per-user resume rewind for the audiobook/podcast player: how many
seconds (0/5/10) before the saved position playback starts, both on
open-resume and again on every genuine unpause. Motivated by the car
startup case — exact resume loses the listener's place, and the
PlaybackRequestResolver path (phone UI, Android Auto, media buttons)
all funnel through one start position.

0 = exact resume (default), so the column is inert until the profile
page sets it. Follows the default_audio_speed / keep_screen_awake
precedent: idempotent ALTER in schema.sql (re-runs on every boot),
explicit GetUser SELECT, dedicated :exec update. Handler-side strict
allow-list {0,5,10} lands with the API slice.
2026-11-01 17:16:35 -05:00
John O'Keefe 0d613ba0e3 fix(docs): folder-index pages expand the sidebar and highlight — same rule for every page
The filter manual is the docs tree's first FOLDER-index page: it serves
at /docs/user/filters while its nav entry carries /index
(/docs/user/filters/index). The sidebar's two checks — group expansion
(containsURL) and active-link styling (docsNavClass) — compared URLs
EXACTLY, so the folder URL matched neither: no submenu expansion, no
highlight (2026-11-01 user report: "the left sidebar should open to
the submenu and highlight the page").

Fix is structural, not per-page: both checks normalize folder-index
URLs (a trailing /index is the same page as its directory), so EVERY
folder-index page — current and future — expands and highlights like
a file page. No per-page registration exists anywhere in the docs
system to redo: the sidebar derives from the docs tree, and this was
the one URL shape its equality rule missed.

Pins: navigation (section + group expand for the folder URL, group
present) and docsNavClass (folder URL highlights the Overview item,
normalization symmetric, file pages keep their highlight, no
cross-highlighting).
2026-11-01 16:34:25 -05:00
John O'Keefe a4dc03984c feat(docs): "?" help affordance on every filter mount + docs loader fixes
The filter manual (1ed1602e) was reachable only by knowing /docs
already existed — nothing in the UI pointed at it (the parked manual
plan's open decision). Every filter-builder mount now carries a "?"
in its header linking to the guide (opens in a new tab so drawer and
editor state survive): the All Books drawer and the collection rules
page — the two mounts sharing FilterBuilderRows. The docs home
indexes the manual (guide, cookbook, generated field reference).

Two docs-loader fixes surfaced by pinning this:
- Folder URLs resolve to their index page: /docs/user/filters now
  serves user/filters/index.md (the manual is a folder: guide +
  cookbook + generated reference); previously only root /docs had
  index handling.
- The docs folder path is resolved by walking upward (bounded):
  `go test` runs test binaries from the PACKAGE directory, so the
  relative "docs" path resolved to a folder that didn't exist there
  and every /docs route 404'd in tests while working in prod —
  found by the new pin, fixed so tests exercise the docs routes
  exactly as prod serves them.

Pins: TestFilterHelpAffordance (folder URL serves the guide; drawer
and rules pages carry the link). internal/docs suite + full suite
green (239s).
2026-11-01 16:22:35 -05:00
John O'Keefe 1ed1602e70 docs(filters): user manual — guide, cookbook, generated field reference
The filter manual (docs/user/filters/), folded in from the parked plan
now that the UI matches the backend (group ALL/ANY header toggle,
2344f568):

- index.md — the guide: the three homes of filters (drawer/saved
  filters, smart collections, dashboard sections), the books/episodes
  scope door, rule anatomy, value picking (chips with x, live counts
  under other rules), GROUPS via the new header toggle (+ the row
  chips as the fine-tuning tool), the live match count, today's
  defaults (reversed listening order, family-lens scoping,
  un-downloaded episodes), saving and shareable views.
- cookbook.md — seven exact-clicks recipes (born-reversed briefing,
  OR-across-authors, big-unread-fantasy, downloaded-unplayed,
  new-arrivals, dashboard shelf, shareable views). Every claim
  cross-checked against the generated reference — which caught the
  one wrong operator label in my first draft ("within last" vs the
  real "within the last"), the drift protection working as designed.
- fields.md — GENERATED from FilterFieldsPayload by
  TestFieldReferenceDocIsCurrent: the drift test fails the build if
  the registry ever changes without regenerating the docs (user
  decision: docs forced to match working code; FILTER_DOCS_REGEN=1
  regenerates).
- searching-and-filtering.md cross-links to the folder.
2026-11-01 15:46:34 -05:00
John O'Keefe 2344f5686a feat(filter-builder): the group header is the ALL/ANY switch
The group header ("MATCH"/"GROUP") was a dead label while the engine
honored group modes all along — buildGroupClause parenthesizes nested
groups and joins each by its own mode, and validation accepted
all/any from day one. The only thing missing was the UI: + Group
birthed ALL groups and no control anywhere could flip one, so the
user's target structures (GROUP(GROUP(... or ...) ...) — 3-level
mixed-mode nesting) were constructible only through the invisible
row-level chips, and standalone ANY-groups not at all (2026-11-01
user report: "MATCH cannot be clicked").

- The header is now the control: MATCH ALL ⇄ MATCH ANY (root — a
  synthetic header row, since the root was never projected and its
  mode was invisible too), GROUP ALL ⇄ GROUP ANY (nested). ANY is
  accented; aria-pressed carries the state. Labels are path-based
  (root reads Match, explicit groups read Group) instead of
  depth-based, which previously mislabeled top-level groups.
- The row-level chips are untouched — they mix AND/OR inside a group
  (A AND (B OR C)), which a global mode toggle cannot express; the
  header is the discoverable entry point, the chips the refinement.
- Dropdown audit (the "would it bug out" check): suggestTree is
  mode-generic — root-ANY drops group siblings, sibling ANY-groups
  survive into the constraint with their mode intact, empty groups
  never emit (no 400s) — now pinned. Deep-nesting Go pin: a 3-level
  mixed-mode tree evaluated through composed search + the suggestion
  endpoint (200, never the empty-group 400). 192 web tests green.
2026-11-01 15:43:52 -05:00
John O'Keefe 5fb80f82ed test(podcasts): the briefing acceptance plays the born-reversed order
Release / build-and-push (push) Successful in 3m32s
The §0-item-2 acceptance pin predates the chain-order system and
assumed the native default: it started the chain at feed position 1
and expected a forward hop. With the born-reversed default (d56031f3),
the page's first card is feed position 5 and the chain runs 5 → 1 —
the acceptance's MEANING is unchanged (play from the first card you
see, chained back-to-back within the live briefing set); the fixture
card it starts from flipped accordingly. Changelogged.

This was the only failure in the full-suite validation run (154s,
green) — the first whole-suite pass across today's four units
(chips, label endpoint, apply-path seed, born-reversed acceptance).
v2.4.1
2026-11-01 13:55:49 -05:00
John O'Keefe 57bc0e9dd4 feat(collections): the rules-Apply path seeds the reversed default too
The creation seed (d56031f3) covered born-smart collections; a STATIC
collection that becomes smart through the rules editor's Apply landed
without any curated order — its dropdown stayed "Feed order" while
every other podcast smart collection opened reversed. Same decision,
same guardrails (2026-11-01 user approved closing this gap): a PUT
carrying an episodes-scope tree seeds chain_order=reverse only when
neither the request NOR the stored view_settings names one — a
deliberate native (client-sent or saved) is never clobbered, and
books-scope trees seed nothing.

Pins: apply-seeds-when-unordered, respects-stored-native,
respects-client-native (TestSmartCollectionBornReversed now covers
both seed paths); changelogged in contract_pins.
2026-11-01 13:44:23 -05:00
John O'Keefe 96a40f7d88 feat(filter): label endpoint names id values for client-held trees
The SSR embed (65f257f3) covers trees the server renders; one path
still fell through to the flawed fallback — applying a SAVED filter
from a cold bookshelf. That page was rendered before any filter
existed (nothing embedded), and the fallback reused the suggest facet,
whose rows are MEDIA-CONSTRAINED: "feed in [A,B,C] AND title contains
Chapter" labeled only the feeds with a matching episode (the exact
live repro from the original bug report).

- GET /api/filter/labels?field=X&ids=a,b,c resolves names straight
  from the entity tables via the shared ResolveFieldLabels core
  (extracted from FilterTreeLabels): no media join, no tree
  constraint. RLS/visibility ride the request identity — a
  zero-episode subscribed feed resolves its title (pinned; the facet
  path structurally could not), an unsubscribed feed id stays unnamed
  (pinned), non-id fields are 400 (pinned), ids cap at 100.
- Client: resolveIdLabels/resolveLabelsAsync use ONLY the endpoint;
  the quiet suggest-fetch mode is deleted (its only caller was the
  flawed fallback). The saved-filter apply flow resolves through the
  endpoint and rebuilds the chips bar when labels land — chips render
  raw ids only while the round trip is in flight, never stuck.
- 188 web tests green; Go endpoint pins + filter/episode slices green;
  contract_pins documents the endpoint and the fallback removal.
2026-11-01 13:40:25 -05:00
John O'Keefe a20741ccc9 feat(filter-builder): multi-pick values render as removable chips
The id-kind value cells (feed/library/collection/added_by) rendered
their picks as one comma-joined string inside a 9rem-wide input — you
saw the first label and nothing else, and reading 3-5 picks meant
scrolling inside a tiny box (worst on mobile, user request 2026-11-01).

Id-kind cells now render a chipbox: each picked value is a chip (label
truncated with a hover title, removable via an X button with a finger-
sized tap target), the box flex-wraps and grows vertically instead of
clipping, and the inline input stays at the end for typing/searching —
including the Backspace-on-empty-removes-last-chip affordance. Text/
number/date cells keep today's single input; the value menu is
unchanged (it never offers an already-picked value, so the chip X is
the faster removal path, not a new one).

Presentation only: the stored wire is still one comma-joined value per
rule; matching, menus, apply, and label resolution are untouched. Pins
moved from valueCellText (now text-cells-only) to the chip model
(idChipsFor/idDraftFor) and cover removal order, last-chip removal, and
Backspace; 187 web tests green.
2026-11-01 13:33:34 -05:00
John O'Keefe d56031f334 feat(collections): new podcast smart collections are BORN reversed
Per user request, same shape as 0e29eff7 — a default flip, no internal
logic changes: POST /api/collections with an episodes-scope filter_tree
now seeds view_settings.chain_order = "reverse" on the new row, so the
collection lands with "Feed order (reversed)" selected in the detail
header's dropdown and the chain/page order follows the stored value as
if picked. Books-scope smart collections and statics get nothing
seeded; an explicit client chain_order is never clobbered; existing
collections are untouched (the seed exists only at creation).

Pins: TestSmartCollectionBornReversed (episodes seeded, explicit keep,
books-scope and static no-seed); TestEpisodeCollectionsFilterTree's
native expectations flip to the born-reversed reality (changelogged).
One misplaced pin from 81e54f89 corrected: the API envelope's match
list is not chain-ordered and never was — order pins live on the SSR
page and reader chain.
2026-11-01 11:53:24 -05:00
John O'Keefe 0e29eff7b7 feat(podcasts): unset listening order defaults to reversed (audiobook-first)
The per-feed listening order — the Order dropdown on the podcast page
and player — resolved its UNSET state to "native": feed document order,
which runs newest-first for classic RSS. This platform is audiobook-
first, so native started every book at its LAST chapter (verified on
live data: 7th Son position 1 = Bonus Episode #2, positions descending
through the chapters).

The unset fallback flips to "reverse" at every resolution site of that
ONE setting — the podcast page's list + dropdown (podcastOrderForFeed),
the player's feed chain (reader.go), and the download queue
(orderOverrideFor) — so the dropdown shows reversed selected, the page
renders in that order, and play/download follow. Stored overrides are
untouched (native stays a valid savable choice); no ordering semantics
changed; collections are untouched.

Pins: the flipped native expectation is changelogged in contract_pins;
new pins cover the sequence config carrying reverse, feed position 1
playing last, and the page dropdown rendering reverse selected for an
unset feed.
2026-11-01 11:46:14 -05:00
John O'Keefe 65f257f377 fix(filter-builder): id labels resolve at render — refresh keeps names
Refresh showed raw uuids in id-kind value cells (feed/library/
collection/added_by). Two defects, one story:

1. Only ids persist in filter trees; the client's id→label cache dies
   with the page. The quiet re-resolution fetch reused the suggest
   facet, whose rows are MEDIA-CONSTRAINED (INNER JOIN media_items +
   the tree's sibling rules as a constraint) — so an id whose media
   doesn't match got no row and no label. Live repro: "feed in [A,B,C]
   AND title contains Chapter" labeled one feed of three (live-request
   evidence: 200 with total:1). A label is a property of the entity
   row, not of what currently matches.

   Fix: pages that render a tree resolve labels SERVER-SIDE at render
   and embed the map on the mount (data-id-labels) — the collection
   editor (stored tree) and the bookshelf (URL qf). New
   SearchService.FilterTreeLabels reads the entity tables with no
   media join and no tree constraint; scoping rides the request
   identity through the scoped handle (podcast_feeds/collections RLS —
   an invisible id resolves to nothing; pinned), libraries mirror the
   facet's visibility join. A feed with ZERO episodes resolves its
   title — pinned; that case was structurally impossible before.

2. The bookshelf drawer never hydrated at page load at all: the only
   load-time hydrate calls live in the PARENT's x-init
   (initBookshelf), and Alpine initializes parents before nested
   components — builderData() was null there, so hydrateFromForm
   no-oped and a URL-qf refresh left the drawer with an empty seeded
   rule while the grid stayed filtered. The drawer now hydrates in its
   OWN init when the mount carries no data-tree; the init-order
   contract is pinned in vitest (185/185).

The quiet suggest fetch remains only as a fallback for trees the SSR
never saw; embedded labels make it a no-op on refresh. Interactive
menus are untouched (their constrained, count-bearing behavior is
correct for choosing).

Pins: TestRulesPageEmbedsIdLabels (zero-episode feed labels at render,
bookshelf qf library label, hidden library NOT labeled);
filter/episode-tree/search-contract/RLS/playlist container slices
green; contract_pins entry documents the embed contract.
2026-11-01 10:44:48 -05:00