65f257f377951016e73a4f2b8b4a940b9c857d00
2164
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
c448fa438f |
fix(filter-builder): one operator name per field (Option A); drawer ids resolve on refresh
The is/in question, settled per user decision: they were exact synonyms (feed's clause compiles is/is_not and in/not_in to the same ANY), so the vocabulary collapses instead of being explained. - Registry: feed's offered ops are in/isn't-in (+has/missing); legacyFieldOps keeps is/is_not ACCEPTED at parse forever — every stored tree and saved filter from before the collapse parses and evaluates unchanged. Library keeps is/isn't (its only names); collection's genuinely distinct operators are untouched. - Client: id-kind cells are uniform — every operator on an id field accepts a set, so all id cells say "pick one or more" and accumulate picks. The per-operator placeholder/pick branching is gone (it also never fired: the registry types is as Values:"many"). - The drawer-refresh id fix rides along: hydrateFromForm now calls resolveIdLabels, so a page refresh with an active saved filter resolves stored ids to names in the bookshelf drawer (the collection rules page already did at init). Pins: feed payload omits the synonyms while legacy feed is/is_not trees parse (Go unit); uniform accumulate + placeholder (vitest); menu-open regression and hydrate-resolution pins kept. Web 183/183; services, filter + episode-tree + search/collections contract slices green (full suite skipped per user — vocabulary + UI-layer change, engine paths pinned by the targeted slices). |
||
|
|
81e54f89b2 |
fix(collections): the order select re-orders the PAGE — visible order = play order
The detail header's listening-order select persisted fine and the reader chain honored it, but the page's card list never applied chain_order (it kept the composed query's title order) — so flipping the select changed nothing visible, and Play as Sequence launches from the FIRST CARD ON THE PAGE, making the played start (and the perceived order) disagree with the curated setting (2026-11-01 user report on the smart podcast page). - Smart episode collections: the page's match list re-orders through OrderEpisodeMedia (the same OrderEpisodes comparator the sequence producer uses — §10 R2's single source of order truth) before card building. Books-scope matches keep title order. - Static episode playlists: the sequence now honors the curated order too — native keeps the manual position order (SQL), reverse flips it, oldest/newest re-key on publication date with feed position as tiebreak. Previously statics ignored chain_order entirely while the select rendered on their pages. Pins: native page order (feed positions) and the reverse flip re-ordering the SSR cards, asserted in the curated-order subtest alongside the existing reader-chain pins. Podcast/playlist/audiobook/ RLS/dashboard/contract slices green. |
||
|
|
178b1f30c5 |
fix(filter-builder): menu opens to the whole list again; arity-aware cells
Regression from the id-label resolution (
|
||
|
|
cce488b76f |
fix(filter-builder): multi-pick id values matched nothing; hint the multi boxes
Comma-joined multi-picks matched ZERO: clauseFeed/clauseLibrary/ clauseCollection parsed each rule value as ONE uuid, but the builder's pick loop stores several picks as a single comma-joined value (["id1,id2"]) — uuid.Parse failed on the joined string, the id list came back empty, and "feeds in [A, B]" matched no episodes (one pick worked, which is why the briefing recipe survived). parseUUIDValues now splits every value on commas (trimming) before parsing, accepting both the builder's joined shape and hand-written separate elements — same leniency the suggest-side id lookup already has. The multi-value cells now say so: id-kind value inputs get the placeholder "pick one or more" (the only boxes on the page that accept several values; every other cell replaces its value). Pins: comma_joined_multi_feed_matches_both — a tree collection built with the exact comma-joined wire the client sends, asserting BOTH feeds' episodes match. Filter-builder vitest 35/35, container filter + episode-tree slices green. |
||
|
|
09f8f0875f |
fix(filter-builder): id labels survive Apply — stored ids resolve to names
The library cell reverted to the raw uuid after Apply: the apply flow reloads the page, the SSR-preloaded tree re-ingests, and idLabels — populated only by suggestion PICKS — starts empty, so valueCellText falls back to the id. Same on any fresh load of a stored id-rule. Two halves: - Server: the id-source suggest query's typed filter (library/ collection/added_by/feed) now also exact-matches a comma-separated ID list alongside the name ILIKE — so a suggest call with the stored ids returns their labeled rows. No new endpoint; the menu and the resolver share one surface. - Client: fetchSuggestions gains a QUIET mode (fills idLabels, touches no menu state), id rows fall back to their stored ids as the typed lookup when no draft is present, and init() calls resolveIdLabels() after tree ingest — one quiet fetch per id-kind rule resolves every stored id to its name from the first paint after reload. Pins: the Go side (suggest with a library's UUID returns the row with the library NAME as display), the client side (resolveIdLabels fills labels, the cell renders the name, no menu opens). 35 filter-builder vitest cases green; container filter slice green. |
||
|
|
ed42111df1 |
fix(filter-builder): OR-aware suggest constraints (user correction)
The previous fix was too blunt: under OR it sent NO constraint tree at all, so "library = ebooks" stopped narrowing the author menu the moment an OR-connected author rule appeared. The user spec is subtler: library = ebooks AND (author = Lewis Carroll OR author = ?) → the menu shows EBOOK authors, with Lewis Carroll OFF the list. suggestTree now walks root → edited rule and builds one conjunctive chain: - ALL-group siblings stay (AND narrows: the ebook scope persists); - ANY-group siblings drop — they are independent alternatives under OR and must not narrow; - EXCEPT a same-field ANY sibling, whose picked values NEGATE into the chain (contains → not_contains, is → is_not, in → not_in): the picked value leaves the menu, everything else in scope stays; - an ALREADY-NEGATED sibling picks nothing to take off, so it drops rather than flipping positive (a positive flip would narrow); - a field without a negation twin falls back to dropping; - incomplete rules never constrain (not filters yet), and the edited rule itself never constrains its own menu. Pins: the user's exact tree (ebook scope kept + Lewis Carroll negated off), the root-OR variant (different-field sibling drops, same-field negates), the twinless fallback, plus the existing AND/OR cases — 34 filter-builder vitest cases green, container filter slice green (TestFilterSuggestionsUnderRules unaffected: the server contract is unchanged, only which qf the client composes). |
||
|
|
c52b514036 |
chore: formatting sweep — vp fmt on drifted web files, templ regen
Pre-existing format drift in seven web/src files (blank-line and indent normalization by vp fmt) plus templ generate normalization across the generated _templ.go files; both swept up while committing the filter-builder fix. No behavior changes. |
||
|
|
28efaf3fe9 |
fix(filter-builder): id cells showed raw ids; OR mode starved the value menu
Two user-reported builder defects:
1. Id-kind value cells (library/collection/feed/added_by) rendered the
stored UUIDs in the input box. The menu offered names (s.display)
and picking stored the id correctly — but the cell bound
rule.values.join(',') raw. The cell now renders through
valueCellText: idLabels give the NAMES; typing drafts a name filter
in idDrafts (the stored values stay ids — the clause matches ids —
and are updated only by picking). Text/number/days cells are
unchanged.
2. The value menu ignored the connective: fetchSuggestions always sent
the sibling rules as the qf constraint, so with "Author contains
Lewis Carroll" present, flipping to OR still starved the author
menu down to Lewis Carroll. Under OR the siblings are independent
alternatives — a new rule's candidates must not be narrowed by
them. pathUnderAll now checks the edited rule's group path: under
AND all the way up, siblings constrain as before (helpful
narrowing); under any OR on the path, NO tree rides and the menu
browses the whole scope.
Server contract unchanged — the suggest endpoint already treats a
missing qf as an unconstrained browse; the fix is client-side
mode-awareness. Pins: 6 new vitest cases (AND sends siblings, OR sends
none, id label display, draft typing never touches stored ids, pick
toggles + relabels, text passthrough).
|
||
|
|
7c7e282e79 |
feat(dashboard): custom-section builder saves again — the flagged tree migration
Archaeology, prompted by the user asking when it broke: the builder's save NEVER worked. The page was born (2026-02-19, |
||
|
|
a86bdac87e |
refactor(collections): retire the legacy rules surface — tree-only storage
The pre-tree rules machinery is deleted: no row ever carried a rules array or a non-default match mode (verified in the dev DB), the web never read the envelope keys, and the Android app consumes none of it. Dead code gets no documentation. Removed: - PUT /api/collections/:id/match-mode — endpoint, route, and the UpdateCollectionMatchMode query. The tree's root mode owns any|all. - auto_assign_rules / rule_match_mode from every envelope (create/list/detail/update), from the create/update request shapes, and from the schema: both columns DROPPED (schema.sql carries idempotent DROP COLUMN IF EXISTS heals for existing DBs; sqlc regenerated). - The rules CRUD service layer (AddRule/SetRuleEnabled/RemoveRule + validateRule + UpdateCollectionRulesJSON), ParseRules / EnabledRules / IsSmartCollection, RulesToFilterTree (+ the SSR preload branch), the legacy branch of EvaluateSmartMembership, and SmartEvalOptions.MatchMode. IsCollectionSmart = tree presence; create-path isSmart = collection_type=smart OR a filter_tree. Kept (live consumers): POST /api/collections/preview still evaluates rules payloads in memory (the custom-section builder's preview), and include_auto stays on the list (the Android app sends it). The custom-section builder's SAVE was already broken — its stringified auto_assign_rules payload failed request binding (probed live: 400 invalid request). Create now rejects that payload loudly with a 400 naming the retirement, instead of silently creating a hollow static section; custom-section-builder.md carries the flag. The builder's tree migration remains the flagged follow-up. Test-side (asymmetry rule — deleted behavior, deleted tests): TestPodcastSmartCollections rewritten onto trees (mode lives in the tree root), match-mode subtests and tree_wins_over_legacy_rules deleted, TestRulesToFilterTree deleted, contract-pin envelope keys retracted per the contract_pins 2026-10-31 retirement entry. Mirrors hand-synced: openapi.yaml, bruno create/update, docs/api/*.md, docs/schema.go, api.d.ts. Full container suite green (155s). |
||
|
|
ab98015190 |
feat(collections): RLS ownership enforcement at the DB layer
collections + collection_items join the RLS-governed tables, same doctrine as the media_items family lens (2026-10-29). Policies route through two PURE predicate functions (app_can_read_collection / app_can_write_collection) that never query collections — no policy recursion under FORCE RLS — so a future sharing model is one EXISTS branch inside the functions, not a schema re-touch. Reads: owner or admin; writes: owner, admin escape for SYSTEM collections only. Why: the write path had no app-level ownership gate on Update/Delete/ AddBooks — any authenticated user could mutate another user's collection by id. The DB is now the enforcement of record; existing app-level gates (match-mode, reorder, view-settings) stay as defense-in-depth. Behavior changes (contract_pins 2026-10-31): - cross-user GET detail: 200 -> 404 (row invisible) - cross-user PUT update: silent success -> 404 (ErrNoRows mapping; service wrap fixed %v -> %w so errors.Is sees pgx.ErrNoRows) - cross-user POST books: silent success -> 404 - cross-user DELETE: 204, now a true DB no-op - anonymous sees nothing; admin persona unchanged Bootstrap: registration seeds system collections BEFORE the JWT exists — CreateDefaultCollectionsForUser declares the new user's identity (rls.WithUser), the same fix mirrored in the test harness (createDefaultCollectionsForUser owner-scoped; collection sweeps run under the owner's identity). Tests: new TestCollectionsRLS pins the cross-user matrix end-to-end (404 detail/update/add/view-settings, 204 no-op delete, admin escape, owner positive control, registration seeding under RLS). Harness fixture writes to non-system collections now run as the row's OWNER (scopeExecAs/ownerSeedCtx) — the admin write-escape covers system rows only, and bare-ctx pool reads of collections see nothing. Filter/playlist/audiobook/scan-state fixtures converted. Full container suite green (159s). |
||
|
|
aaf57a66a2 |
fix(ui): cookie-authed users' writes silently stranded behind bearer gates
THE CLASS: the /api JWT middleware's TokenLookup is
"cookie:token,header:Authorization:Bearer" — the token cookie always
rides same-origin fetches, so a signed-in user never needs the
localStorage bearer. But a dozen call sites gated their fetch on
`localStorage.getItem("token")` and early-returned when absent. The
global 401 interceptor CLEARS that localStorage entry on any session
rotation/blip — after which every gated write silently did nothing:
theme changes reverted on reload (the reported "can no longer change
themes", on top of the interpolation bug
|
||
|
|
744d33e13f |
fix(search): keep the empty-qf 400 at the wire
The empty-tree grammar relaxation (create-modal episodes seed) must not
leak into the search deep-link contract: qf={"mode":"all","children":[]}
as a QUERY would silently mean "match everything". The seed's meaning —
a rules-less collection matching nothing — lives in the stored tree and
its composers; the query entry point now rejects a rule-less tree
explicitly, preserving the pinned 400 that TestFilterQFValidationAndPrecedence
guards (the test caught the leak during this round's full-suite run).
|
||
|
|
d6619cd0e1 |
test(ui): the knob matrix — every control the round-4 defects lived behind
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
|
||
|
|
8aa66a30fc |
docs(api): sync mirrors with the retired rules CRUD and the chain-order endpoint
Three mirrors had drifted:
- The rules-endpoint retirement (
|
||
|
|
4410a994ef |
fix(ui): silence the two benign cancelled-transition rejections
Round-4 console hygiene: fast navigation rejects with "AbortError:
Transition was skipped" (the browser's MPA view transitions —
@view-transition navigation:auto — when a navigation interrupts a
running crossfade) and Alpine's x-transition hide rejects with
{isFromCancelledTransition: true} when a re-show supersedes an
out-phase. Both are races the user cannot lose anything in, but they
print console errors on every quick click — and the knob matrix's
zero-console-error tripwire (the thing that catches silent CSP death)
depends on a clean console. A narrow global gate swallows exactly
these two shapes; every other rejection still reports.
|
||
|
|
aa9bd950f9 |
fix(collections): gate the chain-order endpoint by ownership
PUT /api/collections/:id/view-settings writes the row; it now demands UserOwnsCollection like its sibling config mutations (SetMatchMode, ReorderItems) — otherwise any signed-in user could flip another user's curated listening order. Discovered while documenting the endpoint: the sibling pattern exists precisely because the main PUT/DELETE routes lack it (their gap is noted for follow-up, not expanded here). |
||
|
|
79b47c9680 |
fix(collections): Close/Cancel removed the hidden confirm dialog, not the modal
closeCollectionModal picked document.querySelector(".fixed.inset-0") —
the FIRST overlay in DOM order, which is the sitewide #confirm-modal
that the header renders (hidden, display:none, but present). The real
create/edit modal stayed open: every Close and Cancel click on the
collection modals did nothing. Caught by the knob-matrix spec — the
pencil-glyph case clicked Close and the Edit modal persisted.
The buttons now pass $el and the overlay OWNING the button is closed
(closest(".fixed.inset-0")), falling back to #modal-container's child
for non-Alpine callers. Both the collection create/edit modal and the
restore-system modal take the argument.
|
||
|
|
21142cae5f |
chore(router): remove the CHAINDBG probe — the curated-order path is browser-verified
The debug print served its purpose: the stale-binary native-order render was an artifact of an incomplete rebuild, not code. Fresh container resolves view_settings.chain_order and the chain plays in the curated order (newest-first verified on a 23-episode feed with a date tie at the tail, handled by the position tie-break). |
||
|
|
fd716a7dab |
fix(filters): the episodes-scope vocabulary buried the episode fields under book-only metadata
The scope door's first live test showed the episodes editor leading its field menu with People (Author/Narrator/Contributor) and Publishing — book-only fields that can never match an episode row — while the fields the scope exists for (Feed, Downloaded, Played) sat below the fold. Root cause: the registry's book fields carry no Scopes restriction (nil = all scopes), a leftover from before the episode fields existed. 35 unambiguous book-only fields (people, publishing metadata, topics, series/comics, identifiers, cover, reading-specific statuses) are now tagged Scopes:books. Title, description, published dates, language, file facts, explicit, the my-reading group (listening progress is real), and collection stay dual-scope. Parse continues to accept the hidden fields everywhere (their composed clauses simply match nothing on episode rows) — this governs the AUTHORING surface, not the grammar. TestFilterParseValidation's empty-root rejection updated to the new contract (empty root legal — the seed; nested empty groups still invalid), and TestEpisodesScopeHidesBookOnlyFields pins both scopes' vocabulary. |
||
|
|
536fb4794c |
fix(collections): the create modal's seed POST 400'd — echo cannot bind a form string into an any-typed field
The first real-browser create of an Episodes-scope collection failed with "invalid request": the modal's hx-post submits application/x-www-form-urlencoded, and the form value for filter_tree had to bind into CreateCollectionRequest.FilterTree (any) — echo's form binder errors on any-typed fields instead of setting the string, so every form carrying a tree 400'd before validation even ran. JSON bodies (the save-filter bridge, the editor's Apply) were unaffected, which is why no API-level or Go test caught it: only a browser posting the real form shape exercised the transport. Fix: the form variant binds a dedicated string field (FilterTreeForm, form:"filter_tree") on both request structs while FilterTree keeps the JSON tag; resolveTreePayload picks whichever transport arrived and both ride the same parseRequestFilterTree validation. Pinned by TestEpisodeCollectionsFormSeedCreate (form-encoded seed → 201 → scope round-trips). |
||
|
|
cc0e9d7f91 |
feat(podcasts): the episodes authoring door + curated chain order
"Newest episodes from my selected podcasts" is now buildable in the UI, end to end — the user could not find ANY podcast-related filter surface because none existed: the New Collection modal could only create book-scope smart collections, and the engine's episodes scope (item 3.1) had no authoring door. The scope door: - The create modal's Smart card gains a Rules-scope segmented control (📚 Books / 🎧 Podcast episodes); the copy no longer claims smart means "match books by rules". Choosing Episodes seeds an EMPTY episodes tree ({"scope":"episodes","mode":"all","children":[]}) so the collection is born episode-scoped: the editor opens with the episode vocabulary (feed/downloaded/played/published date) and membership runs the episode engine from the first rule. - ParseFilterTree now accepts an EMPTY ROOT (nested empty groups stay invalid) — the seed's legal shape. The root arrives at depth 1 by call convention, hence the depth>1 exemption. - The composers define the empty-tree semantics: zero children matches NOTHING (ComposedLiveMedia/ComposedCount guards), mirroring EvaluateSmartCollection's zero-enabled-rules convention. Without the guard the composed query degrades to the bare scope head and a fresh collection would swallow every episode in scope — pinned by TestEpisodeCollectionsEmptySeedMatchesNothing (envelope book_count 0) and TestEmptyRootTreeSeedsEpisodesScope. The curated chain order (plan §10 R2, user need: the briefing plays NEWEST-first): - view_settings.chain_order ("native"/"newest"/"oldest"/"reverse") stored on the collection — curated content, not a player-side knob. - PUT /api/collections/:id/view-settings merges the key into the stored JSONB (a blind write would clobber the playlist toggles). - buildSmartCollectionSequenceContext routes OrderEpisodes through handlers.CollectionChainOrder; the detail header's Play-as-Sequence gains the order select next to it (PlayCollectionButton), which PUTs and reloads. Pinned by chain_follows_the_curated_order (reverse flips the chain indexes and hops). Caught by the new test, a latent clobber fixed: UpdateCollection treated a NIL view_settings payload as "write NULL" — any edit that carried no settings dropped every stored key (chain_order, playlist toggles from API clients, imported provenance). Nil now means untouched, the same convention filterTree already had. Composer guards also cover ComposedCount, so the builder's live count reads the same semantics. Tests: collectionChainOrder unit matrix, empty-root parse unit pin, two integration pins (seed matches nothing; curated order drives the chain and survives whole-map edits). |
||
|
|
d5dbaa48ea |
fix(ui): user-test round 4 — five browser-reported control defects
Every one of these was silent at the code level and loud in a real
browser; each fix carries its own tripwire (render pin, unit pin, or
CSP-safe rewrite) so the class cannot quietly return.
1. Theme picker dead since the CSP migration (
|
||
|
|
8aec80cbb7 |
docs(ui): record the 2026-10-30 browser sweep against coverage notes
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. |
||
|
|
94985adb5e |
fix(collections): browser-verification pass over the unified builder/editor
The real-browser sweep (the gotcha classes' native habitat — CSP refusals
fail SILENTLY at build/test time) found four defects, all in the item
3.3/3.4 surfaces:
- Shared builder rows used Alpine attribute expressions that the CSP
parser rejects outright: backtick template literals in :style, the
?. optional chain in :placeholder/x-if, and an if-STATEMENT in a
@click.outside. Rewritten as string concat plus two component methods
(fieldIsDays, dismissSuggestions). Until this fix every drawer open
threw a wall of "CSP Parser Error" console noise and the days-value
affordance never rendered.
- "2 bookss match": the count noun is interpolated into "<n> <noun>s
match", so callers must pass the SINGULAR ("book"/"item").
- The rules editor page received its fields/tree payloads from the
route as already-JSON-encoded strings and wrapped them in JSONArg
AGAIN — double-encoded, so JSON.parse yields a string and the editor
loaded with NO vocabulary and NO prefill. The route now emits raw
JSON into the data attributes (single encode).
- Apply was silently inert: the hook store round-trip used
window.Alpine?.store (optional-chained, no-op when absent) and the
Cancel/Apply block sat outside the builder island scope for part of
the session. Both fixed via the imported Alpine instance and moving
the buttons inside the island; Apply now PUTs the tree end-to-end
(verified live: prefill → edit → apply → 201 → reload → membership).
Browser-verified clean (no fixes needed): picker scope tabs + episode
grid (?scope=episodes door), selection store across swaps, Add Selected
episode membership, library-switcher partial contract (episodes visible
under All Libraries, correctly filtered out under a book library — and
now never OFFERED as a switcher option), episode cards + reorder arrows
persisting position, collection-chain player (Ep N/M, prev/next carry
?queue=collection:, order control absent in collection mode and present
+ persistent in feed mode both in player and feed page), and the
empty-tree Apply guard (400 — a static collection can never be
promoted by accident).
Notes for the next reader of these lines: the OpenChamber panel's
browser.select sets select.value without dispatching change, so htmx
change-triggers must be exercised by real events (Playwright) rather
than the panel; and one 500 was observed on the player's leave-page
progress PUT — a request whose context the navigating browser cancels
mid-write, pre-existing and benign (the immediately following save
returned 200).
|
||
|
|
7bf5697c71 |
fix(podcasts): library switcher leaked literal "continue" and the podcasts option
The D13 podcast exclusion (
|
||
|
|
c54430c254 |
fix(podcasts): code-review pass over the session's UI surfaces
Findings from the systematic gotcha-class review before the browser
pass:
- The collection smart editor's Apply derived the collection NAME from
document.title — which parses to "Smart Collection", RENAMING the
collection on every apply. The server now renders the real name
(data-collection-name) and the write path refuses to proceed without
it.
- TestRenderingPartialsRender pinned the pre-episode-collections
empty-state copy ("No books in this collection yet.") — updated to
the shipped label ("Nothing in this collection yet."; collections
hold episodes now).
- The deleted handler-local rule comparators (evaluateRule/
compareValues — removed in item 3.4's single-evaluator refactor)
left behind two unit-test files testing the duplicates; deleted.
Their semantics remain covered at the service level
(TestEvaluateRules_* over FieldMatches/EvaluateRules) and
TestTextToString (which actually pins utils.PgTextString) stays.
Everything else reviewed clean against the gotcha classes: templ
whole-expression attrs, ?={ boolean attrs, JSONArg quoting in
x-init/data attrs, Alpine CSP store paths, htmx form-encoding, the
" assert escaping, and the aria-label selector contract on the
player's reload listener.
|
||
|
|
94bd69d22d |
feat(filters): save current filter as smart collection — the bridge (item 3.5)
The bookshelf's save modal grows a second action: "Save as Collection" POSTs the current (completed, scope-stamped) tree to /api/collections — the same validated write path every tree collection uses — and lands you on the new collection's page. The qf engine then drives it everywhere: the shelf's live shelf-count, the collections page, the replay chain for episode trees. The legacy "Save Filter" (bookmark the wire params) stays as the sibling action. This is the plan's bridge: a book search graduated into a live shelf with one click, authored in the ONE filter system. |
||
|
|
4f71eda6be |
feat(podcasts): the rules page retires — the collection builder IS the filter builder (item 3.4)
The smart-collection editor at /collections/:id/rules now hosts the SAME builder island as All-Books (FilterBuilderRows — the rows markup extracted into one shared templ both mounts render; the shared filterBuilderExtra component). One vocabulary, one engine, one row UI; the collection differences are exactly three: the payload is the collection's scope (episode trees get the episode fields — books trees hide them), Apply writes the tree to the collection (PUT filter_tree through the validated write path), and a legacy rules array preloads as its CONVERTED tree (RulesToFilterTree rendered server-side) so the upgrade lands at the first Apply — read-compat never mutates data silently. Retired with the page: the per-rule CRUD endpoints and test-rules (their handler bodies, routes, api-docs rows, and the dead collections.ts island functions — the file's LIVE halves, the collection modal/navigate/ grid-refresh, stay; the dead rules half went). match-mode endpoint and preview survive (legacy channel + the dashboard custom-section-builder, flagged for its own migration). Contract-pin changelog documents the deletions per the asymmetry rule. The builder component gains collection mounts: scopeKey (the row universe for counts/apply), data-tree prefill, treeJSON (the apply wire with scope riding the top level), and an apply hook (the page owns the write; All-Books never sets one). ensureReadyRule defaults to the scope's first field instead of hardcoding author. Tests migrated onto trees: TestSmartCollections' live-membership / reject-mutations / include-auto / system-collection subtests now drive TREE collections; the deleted-endpoint subtests die with them. |
||
|
|
eb68672390 |
feat(filters): one vocabulary, server-rendered — the TS mirror retires (item 3.3)
"One filter system to maintain" now holds at the presentation layer too: the field/op list the builder renders comes from services.FilterFieldsPayload(scope) — the Go registry's wire face, scope-filtered and group-ordered — rendered into the bookshelf mount as a data attribute (templates.JSONArg), ingested once by the Alpine component. The registry carries operator presentation (labels, negation, arity) in FilterOpWire alongside the grammar; per-field value-cell semantics (valueKind) derive from the existing Kind. The hardcoded TS mirror (OPS, op(), F(), TEXT_OPS/NUM_OPS/DATE_OPS, FIELD_GROUPS, its known drift surface) is deleted; filter_builder.ts now ingests the wire (ingestFilterFields — malformed payloads leave empty menus rather than client-invented rows), and the field menu's episode fields are HIDDEN on the All-Books page for free (the books scope payload excludes them — the plan's "hide podcasts from the rules on the all books page" is now structural, not enforced by a second hand-kept list). Unit tests ingest a minimal wire fixture (the payload shape is the contract, not the mirror); vitest 167 green, tsc + vp clean. |
||
|
|
6b2125a45a |
feat(podcasts): smart collections ride the unified filter engine — filter_tree column (item 3.2)
collections gains filter_tree JSONB (idempotent in-place; same qf DSL as saved filters — the scope key included). IsCollectionSmart is the ONE detection (tree OR legacy rules), and EvaluateSmartMembership is the ONE evaluator: tree-bearing collections run the composed engine via ComposedLiveMedia (full media rows, so the collection surfaces keep their episode-card stamping, feed positions for the replay chain, and the family lens rides the mandatory head), while legacy rule arrays keep per-item FieldMatches until rewritten — one call surface, two storage epochs. All smart surfaces (detail JSON, counts, detail page, reader chain) dispatch through it; the search service is injected into the collection subsystem (variadic — main.go builds one shared instance; the test harness mirrors it). Write path: CreateCollection/UpdateCollection accept the tree, validated against the engine grammar at the HANDLER (an invalid tree is a 400, never a stored render-time 500) and exposed in the envelope (pin widened in TestContractPin_CollectionsJSON + changelog). Update semantics: an edit form that doesn't send a tree keeps the stored one. RulesToFilterTree converts legacy rules + match mode at upgrade time: episode rules map mechanically (feed→in-list, played/downloaded→ booleans, published_within→published_date+within_days), tree root mode absorbs rule_match_mode, episode rules force the episodes scope, and legacy starts_with/ends_with fall back to contains (documented). Read-compat by design: nothing is migrated at boot — legacy arrays evaluate exactly as before until the collection is rewritten. Pinned by TestRulesToFilterTree (unit conversion) + TestEpisodeCollectionsFilterTree (a tree collection evaluates through the engine, the chain follows the tree's live membership, invalid trees 400 at write, the tree wins when legacy rules coexist, and an edit without a tree keeps it). |
||
|
|
56871b65ed |
feat(filters): scopes + episode fields — the engine becomes the single query system (item 3.1)
The qf tree gains an optional top-level `scope` ("books" default |
"episodes" — absent = books, so every existing saved tree and legacy
client stays valid). The row head is scope-aware AND this closes a
real leak: the composed engine's head had NO podcasts exclusion, so
episodes already surfaced on All-Books qf queries (plan §9 violation —
the sqlc flat head excluded them, the composed head didn't). Books
scope now says library_type_name <> 'podcasts'; episodes scope is
source = 'podcast_episode'.
The registry fields declare their scopes (the plan's "reusable
template with conditionals" = scope-driven rendering in 3.3): episode
fields declare exactly the episodes scope and the books scope REJECTS
them at parse (the "hide podcasts from the rules on the all books
page" rule enforced server-side); books fields inside episodes trees
are allowed and honestly no-op on NULLs.
New fields (the item-2.4 rule vocabulary expressed in SQL):
- feed — KindIdentifier over podcast_feeds (RLS-governed suggestion
source and match; unsubscribed feeds match nothing, by the engine)
- downloaded — is_true/is_false on download_state = 'downloaded'
- played — THE canonical finished predicate (viewer progress >= 0.999,
the hide_finished definition shared with item 2.4), reading_progress
join per viewer
- published_date — clauseDateFactory like added_at, within_days
default op (the published_within rule's SQL form)
Engine hygiene: the stray ComposedSearch SQL debug printf is gone.
Pinned by TestFilterScopeValidation (unit: scope grammar, books×
episode-field 400s, legacy no-scope trees) and
TestFilterScopesAndEpisodeFields (integration: books head excludes
podcasts including the 404-no-results contract, episodes head is
episodes only, feed-in/downloaded/played/published-date evaluate in
SQL against a subscribed-feed fixture with a played viewer).
|
||
|
|
bc91ce68d4 |
test(pins): widen whitelist for the episode-collections JSON shapes (item 2 series)
TestContractPin_CollectionsJSON tripped on the additive keys the episode-collections work introduced — the tripwire worked exactly as designed (additive keys must be whitelisted here AND in docs/contract_pins.md first). - Collections envelope gains rule_match_mode (item 2.4): the smart-collection match semantics on GET /api/collections/:id. - BookInfo gains four STABLE keys (item 2.3): is_episode, feed_title, download_state, hidden — dropped omitempty so every card surface emits the same key set (the viewer_percentage precedent; adapters that predate episode awareness emit zero values). docs/contract_pins.md changelog entry written. Nothing removed, nothing renamed. |
||
|
|
962f9089e0 |
chore(db): querier interface picks up the UpdateCollectionViewSettings comment — generated side of cc56b9ef
|
||
|
|
69773ec307 |
feat(podcasts): smart collections play as a sequence — the Daily Fiction acceptance (item 2.5)
ACCEPTANCE (plan §0 item 2.5): "Daily Fiction" = feed ∈ {A} ∧ unplayed
∧ <7d matches exactly today's episodes, and PLAY = today's episodes
back-to-back. Two pieces were missing after item 2.4:
- The chain builder only read static membership (collection_items) — a
smart collection has none. buildSmartCollectionSequenceContext now
evaluates the rules LIVE for the requesting user (mode from the
collection, family lens via the visible set) and chains the
downloaded episode matches in NATIVE feed order — the collection's
computed order is content (R1: never written back; OrderEpisodes is
the order chokepoint per R3). A match that aged out of the briefing
(played since, published beyond the window) falls back to the feed
queue instead of breaking the page.
- The smart-detail branch rendered match cards as books: episode
matches are now flagged (IsEpisode + download state) so they carry
the play affordance and the header's Play-as-Sequence button;
feed titles stay empty on smart cards for now (batch lookup is
polish, not acceptance).
Pinned by TestPodcastSmartCollections' acceptance subtests: the
briefing starts at index 1/2 and chains into the second with the
queue param on both hops; the order control stays hidden (computed
order); a stale match falls back to the feed queue honestly.
|
||
|
|
cc56b9ef2a |
fix(collections): UpdateCollectionViewSettings wrote a nonexistent updated_at column
The query said `SET view_settings = $2, updated_at = NOW()` but the collections table has never had an updated_at column — every call failed with 42703. The one caller (media_scanner's series-import provenance write) discards the error, so the failure was silent: imported-from-series view settings never persisted. Surfaced while writing the sibling UpdateCollectionMatchMode query for item 2.4 (the item-2.4 commit message prematurely claimed this fix — this is that fix, for real). |
||
|
|
abb7c84833 |
feat(podcasts): smart episode collections — ALL/ANY match modes + episode rule fields (item 2.4)
The daily briefing needs AND: today's rules engine was OR-only. The
match semantics become a per-collection column
(collections.rule_match_mode, 'any' legacy default | 'all'), flipped on
its own endpoint (PUT /api/collections/:id/match-mode, owner-only —
nothing else on the row changes) and surfaced in the rules page as an
ANY/ALL toggle with matching copy.
Episode rule fields join the vocabulary (plan item 2.4): feed (the
multi-select rides the new `in` operator — comma-separated feed ids),
played (the canonical finished predicate — viewer progress ≥ 0.999,
same as hide_finished), downloaded, published_within (N days), plus
title/description contains. The rules candidate query now carries the
viewer's progress (LEFT JOIN reading_progress) so played/unplayed is
per-user, and TestRules/preview speak the SAME evaluator: the handler's
duplicated field switch is deleted and delegates to
CollectionService.FieldMatches — one vocabulary, one semantics.
Legitimate semantics note: in ANY mode `played:false` matches unplayed
BOOKS too (everything with no progress row) — correct per-rule
behavior, and exactly why the briefing mode exists.
Backward compatibility: every legacy row defaults to 'any' (the old
OR), UpdateCollection keeps the stored mode when the form doesn't send
one, and the evaluator treats absent data honestly (no date → no
match; no progress → unplayed).
Pinned by TestPodcastSmartCollections: the AND-mode briefing
(feed∈{A} ∧ unplayed ∧ <7d = exactly the new episode), OR-mode
widening, owner-only flips, the preview honoring the mode, and
downloaded/contains rules. Also fixes a latent bug the new query
surfaced: UpdateCollectionViewSettings referenced a nonexistent
updated_at column (never exercised until now — the new sibling query
was written without it).
|
||
|
|
f68fb19afb | test(ui): COVERAGE.md — episode-collection sweep candidates (item 2.3) | ||
|
|
adb93cba9e |
feat(podcasts): episode collections — picker scope, hint rows, the chain (item 2.3)
D11 lifts: podcast episodes (media_items rows) join collections, and "Play collection as sequence" feeds the player from the collection's order. No new tables — collection_items already carries ordered membership (item 2.1 audit). PICKER: the collection modal gains a Books/Episodes tab pair. The episodes tab hits /api/media-items/search?scope=episodes — a dedicated picker query (SearchEpisodesForPicker) whose visibility is entirely RLS: the media_items subscription predicate drops unsubscribed feeds' episodes, and the podcast_feeds join is governed the same way. The books scope keeps its podcasts exclusion (plan §9) — this scope is the only door in. Pending episodes are pickable (plan ahead; the detail page shows them as not downloaded). Selection rides the existing bookPicker store — ids never collide across scopes, and AddBooks works unchanged on episode ids. DETAIL: the All-Libraries membership view switches to GetCollectionItemsDetailed — LEFT JOIN + per-row visibility. An unsubscribed episode (or family-lens hidden book) survives as a Hidden card rendered as an "Unavailable — remove entry" hint row instead of silently vanishing; visible episodes render feed title, download state, and a Play link. The library-scoped view keeps its existing dashboard-query behavior (the switcher's contract is untouched for books). CHAIN: ?queue=collection:<id> on any reader URL makes buildPodcastSequenceContext produce the COLLECTION context (§10 R2 same shape, source=collection): downloaded episodes in collection order (positions are content — R1; no feed-order override), prev/next URLs carrying the queue param so the chain survives hops, and backToDetail landing on the collection page. Ownership gates the chain — foreign/deleted ids fall back to the feed queue; the order control stays hidden (2.2's source gate). The header's "Play as Sequence" button starts from the first downloaded episode in list order. Pinned by TestPodcastEpisodeCollections (7 subtests): RLS-scoped picker + family-lens auditor case (subscribed episode in a hidden library must not reach the picker), books-scope exclusion intact, hint rows, chain carrying the queue param, start-from-first, foreign fallback, and reorder driving the chain (the R4 primitive works on episode ids unchanged). |
||
|
|
a90e7debfd |
feat(podcasts): queue context source + player order control — item 2.2
Foundation rule R2 (§10): the queue context is the only queue, and every producer emits the same shape. PodcastSequenceContext gains source (feed|collection), feed_id, and order; the feed producer sets them now — the collection producer (2.3) slots into the same shape without the player ever special-casing beyond showing/hiding controls. The player header gains the FEED-mode order control next to the "Ep N/M" counter: a dropdown (native/reverse/oldest/newest) plus a one-click reverse button. Both PUT the existing per-user feed order override — the SAME state the feed page writes, so flipping it in the player updates the feed page's dropdown too. The control renders only when source=feed: a collection queue's order is curated content (R1), never a listening preference. Behavior is hx-put + hx-swap="none" (response discarded) with a module-level htmx:afterRequest listener in audio.ts reloading the page — the server re-renders prev/next/index in the new order; templ decides what it looks like, JS only triggers the round trip (CSP-clean, no inline handlers). Pinned by TestPodcastPlayerOrderControl: the config JSON carries source/feed_id/order/episode_index; the control renders feed-mode; an order override re-renders with the dropdown reflecting it (data- json pins must match templ's " attribute escaping). |
||
|
|
7bf0b1257d |
feat(podcasts): widen reader_settings key ceiling — item 2.1 foundation
reader_settings.setting_key was VARCHAR(50), and the composite podcast_order:<uuid> key already sits at exactly 50 chars — zero headroom. The deferred queue-editor idea (plan §0 item 2b) and any future per-feed/per-collection preference key would have been forced to payload-stuff inside the order row by a column width. Composite prefix:<uuid> keys are the norm now (§10 R5), so the ceiling widens to VARCHAR(120): idempotent in-place ALTER (varchar widening is metadata-only, no table rewrite), re-runs harmlessly on every boot per the schema convention. No new episode-membership tables: collection_items already carries ordered playlist membership (position, A2c) against media_items, which podcast episodes ARE — episodes join collections through the existing table. Pinned by TestReaderSettingsCompositeKeyHeadroom: a 57-char composite key (the podcast_custom_order:<uuid> shape) upserts and reads back. Under the old width this fails; sqlc codegen is byte-identical. |
||
|
|
f7ba4d24d6 |
style(web): main sidebar drops 16rem to 14rem (user review)
Release / build-and-push (push) Successful in 3m38s
The app-shell sidebar read as too wide against the content area. --sidebar-w is the single source of truth for the shell geometry, so one line moves all three consumers together: the .app-sidebar width, the body:has(.app-sidebar) content offset, and the topbar's left inset on desktop. No templ or JS change needed — nothing else pinned 16rem. style.css is the regenerated tailwind build (build:css:prod), diff limited to the one var; committed with its source per the generated- artifacts rule.v2.4.0 |
||
|
|
f47734dd7d |
fix(podcasts): generic media delete refuses episode rows (invariant 2)
The admin endpoint DELETE /api/media-items/:id predated podcasts and would delete any media row — including podcast episodes — if hit directly. Invariant 2 (absolute form, user decision 2026-10-30) says episode rows are removed ONLY by whole-feed removal: "the feed should match the XML until the entire feed is gone." The plan audit found this endpoint was the one remaining surface that could violate it: the poller, unsubscribe, and the admin file-deletion endpoints are files-only, and the hash-conflict merge can't see episodes (they never hash). The guard sits right after the GetMediaItem read and BEFORE the storage-down check, so a refused episode never touches storage resolution. Same segregation pattern as the generic edit path (D9/D10, UpdateMediaItem). Pinned by TestDeleteMediaItemPodcastGuard: admin DELETE of an episode → 409 with the row surviving; a book delete through the same endpoint → 204 control. |
||
|
|
5fee8bfb4a |
fix(admin): bulk delete swaps the episode list — Play → Download without reload
The bulk 'Delete selected files' button posted with hx-swap='none', which makes htmx DISCARD the response — and the response is the refreshed episode list partial. Rows (Play → Download, badges) stayed stale until a manual refresh. Target the swap at #episode-list / outerHTML like the Download All button; the afterSwap listeners then reapply the (now-empty) selection and any active filter. |
||
|
|
7d5bbd1177 |
feat(admin): multi-select episode file deletion — checkboxes on downloaded rows
The plan's per-EPISODE bulk select for the admin view (§0 item 8 follow-up — trimmed in the first admin-view commit; user called it back). Admins now tick checkboxes on DOWNLOADED episode rows and delete their files in one request: - Checkbox per row, rendered ONLY for admins + downloaded episodes (nothing to delete otherwise), via the existing podcastDetail Alpine component: selection ids/count survive the list's htmx swaps (reapplied on afterSwap — same pattern as the text filter) and clear after a successful bulk delete. - Toolbar segment outside #episode-list (survives swaps): 'N selected' + 'Delete selected files' (hx-include gathers the CHECKED boxes); disabled-looking at zero selection; hx-confirm states the re-materialization contract. CSP-safe method bindings only (bulkSelectionLabel/Class) — no ternaries in Alpine expressions. - POST /feeds/:id/episodes/delete-files (admin): repeated episode-file-select keys parsed via ParseForm directly (never bound — repeated-key wire is another echo-bind gotcha); each id validated to belong to THIS feed, so a forged foreign id is refused while the real selections apply; rows reset to pending, files removed. - Tests: bulk of two real ids + one forged id → deleted:2, files gone, rows pending; non-admin 403; all prior single/feed-level pins hold. |
||
|
|
0741e98ea5 |
feat(admin): all-podcasts view — disk-first maintenance for every feed
§0 item 8 (Phase-4 item 1 pulled forward, user directive: maintenance
tooling ships WITH the features it maintains):
- /admin/podcasts (AdminMiddleware): every feed from EVERY user via the
RLS admin escape (first-admin identity, poller/fetcher contract) —
feed + host, library, subscriber count/names, downloaded/total, DISK
USAGE, last fetch. Sorted disk DESC ('what's eating the disk'),
totals header (feeds · episodes · bytes on disk).
- Per-feed actions: Refresh · Delete files (rows → pending, disk freed;
confirms spell out the re-materialization contract) · Remove feed
(rows + files for everyone — the same admin delete as the detail
page). All confirms ride the global confirm modal.
- New sqlc: ListAllPodcastFeedsAdmin (correlated aggregates),
ResetPodcastEpisodeFileDeleted / ResetPodcastFeedFilesDeleted —
downloaded rows reset to pending with download-derived facts cleared
(audio_metadata, chapter_count, cover, size, mime); file_path KEPT
(fetch-maintained layout — a re-download lands on the same path).
file deletion via the existing DeleteFeedFiles file-removal pattern
(episode files + .cover.jpg siblings).
- Poll upsert never touches download_state, so deleted files STAY
deleted — a feed refresh re-lists metadata but re-materializes
nothing (pinned by TestAdminDeleteEpisodeFiles).
- Per-episode admin delete-file: POST /feeds/:id/episodes/:eid/
delete-file (admin) — one episode's file gone, row pending, siblings
untouched; HX swaps the episode list. (Found while testing: the
handler first read c.Param("id") — the FEED's uuid — instead of
:eid; the 404 'episode not found' trail led straight to it.)
- Tests: admin sees other users' feeds + subscriber names; regular
users 403 on page AND actions; single + feed-level deletion reset
rows/clear facts/delete files; refresh leaves pending rows pending.
- Admin nav gains 'Podcasts' under Administration.
Wrong-gotcha note for future agents: parseUUID on c.Param('id') inside
a /feeds/:id/episodes/:eid route resolves the FEED, not the episode.
|
||
|
|
b293741eb1 |
feat(reader): podcast episode prev/next on the transport row's outer buttons
User design (round 3): one set of big buttons does the big navigation. The outer prev/next step buttons were chapter-only even on podcasts, while episode nav hid as small icons in the top-left cluster. Now: - Podcasts (sequence context present): the outer buttons navigate EPISODES via goToPrev/goToNextEpisode (progress pushed before the hop); disabled ghost buttons at the first/last episode keep the row shape. Embedded-chapter jumps stay on the Chapter pill's list. - Books: unchanged chapter stepping. - Keyboard (h/l shortcuts) and hardware media keys (MediaTrackPrev/ Next) funnel through stepChapter, which is now sequence-aware — headset next-track = next episode on podcasts. - The top-left episode icons are gone; the 'Ep N/M' counter stays. - Help-modal labels say 'chapter, or episode on podcasts'. Same icons both modes (extracted audioStepPrev/NextIcon partials); books are untouched — no sequence context, byte-identical behavior. |
||
|
|
ace9e57d27 |
fix(ui): hx-confirm interception actually worked — htmx 2 detail shape
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
|
||
|
|
2fd0828c06 |
fix(podcasts+ui): user-test round 3 — five browser-reported defects
1. Add-modal 'Download order' never revealed: the CSS sibling selector
(#podcast-download-all:checked ~ .podcast-add-order) can't fire —
the checkbox was nested inside a wrapper div, so the select row was
no SIBLING of the input. Restructured: input, label, and order row
are now siblings under one flex-wrap wrapper.
2. Order + auto-download dropdowns always displayed the LAST option
('Newest first'/'Newest') regardless of the saved value: templ's
selected={ boolExpr } compiles to selected="false" — and the mere
PRESENCE of the attribute selects the option, so every option was
selected and the browser honored the last. Sweep to the boolean-
attribute form selected?={...} (omits on false) across
podcast_detail (14), book_detail_modals (10: manga type/reading
direction selects + abridged/explicit checkboxes — same defect,
always-checked), collection_modal (2: hide-finished/show-up-next
always-checked). Values were SAVING correctly all along; this was
pure display. audio_reader/collections :selected/:checked are
Alpine bindings — unaffected.
3. Remove Feed (and every hx-confirm) still showed the browser dialog
for signed-in users: the global confirm modal rendered at the end
of SidebarSignIn — signed-IN pages have no SidebarSignIn, so no
#confirm-modal existed and confirmDialog() degraded to
window.confirm. Moved the modal into Header proper (rendered on
every page, after </aside>).
4. Facet bar at scale: values now carry feed COUNTS, sort by frequency
DESC then alphabetically, cap at 6 chips per group flat, and the
rest collapse into a native <details> '+N more' expander — hundreds
of hosts/tags degrade gracefully instead of a wall of chips.
Verified live on the dev instance (order select reflects the saved
override, override row persists, reveal fires, modal renders on
signed-in pages). Podcast + logout + media-pin suites green; vitest
167 green.
|