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).
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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).
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).
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.
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.
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).
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.
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.
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.
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.
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.
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.
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).
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.
Regression from the id-label resolution (09f8f087): opening an id-kind
value menu carried the STORED ids as the typed filter, and the new
id-exact-match narrowed the menu to exactly the already-picked rows —
nothing else was visible, so "can't add more than one" (and clicking
the lone visible row unpicked it). The ids now ride as the typed
lookup ONLY in quiet mode (label resolution); a real menu open browses
(blank) or filters by the typed NAME draft.
The multi-value affordance is now ARITY-AWARE, per user request: the
op decides how many values the cell takes, and both the hint and the
pick behavior follow it — "in" says "pick one or more" and toggles
ids into the comma list; "is" says "pick one" and REPLACES the value.
Changing the operator updates the hint in place.
Pins: menu-open after a pick must not carry the stored ids (the
regression), one-arity pick replaces instead of toggling, and the
placeholder follows the op's arity. 38 filter-builder vitest cases;
web suite 183/183; container filter + episode-tree slices green.
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.
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.
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).
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.
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).
Archaeology, prompted by the user asking when it broke: the builder's
save NEVER worked. The page was born (2026-02-19, a92bf99a) posting
auto_assign_rules as a JSON-encoded STRING into an endpoint that has
bound []Rule since its creation (2026-01-31, 6495cc2c), plus fields
(manual_book_ids, match_type, library_id, show_on_dashboard) the
endpoint never accepted. The preview button hit a real endpoint, so
the page looked alive while Save always failed at request binding.
Verified live before this change: the page's exact payload 400'd
"invalid request" against the unmodified server.
The fix adapts the builder's payload at write time — the legacy rules
vocabulary converts to a filter tree (RulesToFilterTree restored from
the retirement commit as the single write path, unit test restored
with it):
- match_type seeds the converted tree's root mode (any default).
- manual_book_ids attach to the created collection after create
(unparsable ids skipped — the AddBooks leniency).
- show_on_dashboard persists (new create field).
- Converted trees validate under ParseFilterTree: an unhonorable rule
is a 400 at write naming the problem, never a stored 500 at render.
Dashboard sections: smart user sections now evaluate their tree LIVE —
GetUserCollectionItems merges stored membership with the composed
engine's matches (deduped, library-scoped, capped at the section
limit). This exposed a REAL production wiring bug: every production
NewDashboardService call omitted the search service, so tree
evaluation through the dashboard could never have worked (the test
harness wired it correctly — the gap was production-only and latent,
because nothing in the dashboard evaluated trees before this
migration). The dashboard handler and both inline sites now receive
the search service, and evaluation failures log instead of silently
emptying a section.
Supersedes the same-day loud-400 stance for the builder's payload
(contract_pins migration entry); custom-section-builder.md documents
the save contract; the create_collection API doc gains the adapted
fields. New TestCustomSectionBuilderSave/BadRule pin the payload →
tree → dashboard-render path end to end. Full container suite green
(155s).
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).
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).
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 d5dbaa48 fixed), chain-order
selects, collection deletion, Add-to-collection submits, filter
save/load/delete, conflict actions, linking actions, stats charts,
tag suggestions, device management, admin scanner actions — all dead
for perfectly authenticated cookie users.
Fix: storage.ts exports authHeaders() (bearer when present, cookie
carries otherwise) and every gate/header site now uses it — theme.ts,
collections.ts (htmx auth, doRemoveBook/doBulkRemove, setChainOrder),
bookPicker submit, filter_builder suggestions + match count,
collection-rules apply hook, bookshelf save/load/delete filter,
conflicts x4, linking x3, admin scanner actions + polls (requireToken
removed — its "Session expired" toast fired for cookie users too),
dashboard preferences/sections, custom-section-builder, header
scanner poll, stats x3, tag suggestions, profile delete-account
(which also wrongly redirected cookie users to /login), and
device-management x4. Reads keep their graceful-empty behavior; the
bearers still ride along when present.
Pinned: the knob-matrix theme spec now REMOVES localStorage.token and
asserts the theme PUT still fires (cookie-only) and the class flips —
the exact strand that started this.
Found by noticing the panel browser's Nord click switched the class
but produced no PUT: the persistence half was gated on a token the
401 interceptor had cleared.
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).
Six specs driving the user-reported controls the way a human does,
encoding the sweep rules COVERAGE.md now states: menus/modals assert
VISIBILITY (never DOM text — hidden menus' text is why the 2026-10-30
panel sweep certified dead controls), geometry asserts the z-ladder
via elementFromPoint and viewport containment via boundingBox, every
click must produce an observable outcome (PUT fired, class flipped,
chip text swapped) and zero new console problems — the silent
CSP-throw family (theme buttons, connective chip, invisible op menu)
now fails a test instead of surviving to a user's mouse.
Covers: theme picker (interpolation regression from eb57b720),
pencil-glyph vs card-body contract, builder field menu / op menu
(full non-empty vocabulary) / connective both directions, the
Smart→Episodes create-modal seed, the picker overlay's ladder and
height, and the chain-order select's persistence round-trip.
Fixtures self-create/self-delete under test-prefixed names; localStorage
token + selectedLibrary=__all__ are seeded because the API login
establishes only the cookie; run the folder with --workers=1 (the login
route rate-limits and all suites share testuser — a parallel worker's
login revokes another's mid-run).
Three mirrors had drifted:
- The rules-endpoint retirement (4f71eda6) deleted the routes but not
their mirrors: removed docs/developer/api/collections/
{add_auto_assign_rule,remove_auto_assign_rule,test_rule,update_rule}
.md + their bruno twins, and pruned /api/collections/test-rules,
/{id}/rules, /{id}/rules/{ruleId} from the generated openapi spec
(hand-edited — regenerating with -update would wipe hand-authored
bruno suites that predate the docs mirrors).
- New endpoint PUT /api/collections/:id/view-settings gets a doc page
+ schema-registry entry + bruno mirror (repo convention: the route
and its docs land together).
- 54 pre-existing TestDocsMatchRoutes failures: the media-item
endpoints emit the 16 episode/download fields (source_feed_id,
feed_position, download_state, enclosure_url, hosts/guests, …) and
libraries emit purpose/allow_member_feeds, but the doc examples
were never extended — the package only runs on the host, so the
container suite never caught it. Examples now carry every key the
handlers emit; internal/docs is fully green.
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.
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).
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.
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).
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.
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).
"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).
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 (eb57b720): the Appearance
panel's theme buttons emitted `@click="selectTheme(opt.Name)"` as
LITERAL text (templ gotcha #1 — quoted attribute values are never
interpolated; `opt` is a Go loop var), so Alpine threw "Undefined
variable: opt" on every click and no PUT /api/auth/theme ever fired.
The wood-paneling sibling always used the correct expression form,
which is why the panel half-worked. Fixed to the interpolated form;
TestThemePickerHandlersAreInterpolated pins that real theme names
reach the HTML and the raw identifier never does.
2. Filter-builder op dropdown "does nothing": the field ingest
hardcoded `ops: []` into every field's meta while only mirroring the
ops into the global label registry — opsFor() returned [] for EVERY
field, so the op menu rendered zero items: an invisible menu, no
console error (user tried author/publisher/title/subtitle, all
equally dead). FieldMeta.ops is now the wire's op objects, opsFor()
is a standalone exported helper, and opsForField unit tests pin the
round-trip. Verified live: the field menu opens/closes while the op
menu stayed shut, both with identical wiring — the difference was
the data, not the wiring.
3. AND/OR connective chip dead: the chip carried an `if` STATEMENT in
the Alpine attribute (`@click="if (row.chipClickable) ..."`) which
the CSP build's evaluator rejects as a statement — the whole
expression silently never ran. Moved the clickability check into a
toggleConnective(row) component method; the attribute is now a plain
method call.
4. Pencil vs card navigation race: navigateToCollection guarded with
`event.target instanceof HTMLElement`, which is FALSE for the
pencil glyph's SVGElement — a click landing on the glyph sailed past
the guard and navigated while the htmx edit-modal request still
fired (the reported "modal flashes, then the builder page"). The
guard now uses target.closest("button") (works on SVGElement) and
takes $event explicitly instead of the deprecated window.event.
.icon-btn is already a 36px square, so no hit-area change needed.
5. Add Items modal under the sidebar + taller than the window: the
picker overlay sat at z-50 UNDER the app-sidebar's z-60 (its left
edge rendered beneath the nav), and the card had no height bound —
header + tabs + filters + grid + footer overflow short windows with
the footer unreachable (fixed overlay, no page scroll). The picker
and create-modal overlays move to z-[65], both confirm dialogs to
z-[70], and the picker card is now a flex column capped at
max-h-[85vh] whose grids flex-fill (they grow on tall windows,
shrink on short ones). The ladder is documented at .app-sidebar in
input.css: sidebar 60 / overlays 65 / confirm 70 / toasts 9999.
Found by clicking every control the way a user does (panel clicks +
computed-style reads + screenshots) after the previous sweep trusted
DOM-text snapshots, which show hidden menus' text and cannot see
occlusion, stacking, or geometry at all.
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.
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).
The D13 podcast exclusion (9a148c23) was written as a templ
`if !IsBookLibrary(...) { continue }` — but templ markup control flow
has NO continue STATEMENT: codegen emitted the word as a TEXT node
(visible junk between options on every page's library subbar) and
rendered the podcasts option anyway, defeating the exclusion. The real
browser sweep caught what every Go/API test structurally cannot:
generated markup that renders wrong yet compiles clean.
Rewritten as an inverted guard (the idiomatic templ skip). New render
pin TestLibrarySwitcherExcludesPodcasts fails on both halves of the
original leak — the literal text AND the presence of the podcasts
option — so this codegen gotcha class stays tripped-wired.
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.
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.