From 94985adb5e589036b723383b5f14ebdcbf55613a Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 30 Oct 2026 20:50:55 -0400 Subject: [PATCH] fix(collections): browser-verification pass over the unified builder/editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " 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). --- internal/router/frontend.go | 6 +++++- templates/bookshelf.templ | 2 +- templates/bookshelf_templ.go | 2 +- templates/collection_rules.templ | 10 +++++----- templates/collection_rules_templ.go | 16 ++++++++-------- templates/filter_builder.templ | 12 ++++++------ templates/filter_builder_templ.go | 2 +- web/src/collection-rules.ts | 7 +++++-- web/src/filter_builder.ts | 22 ++++++++++++++++++---- 9 files changed, 50 insertions(+), 29 deletions(-) diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 04f0ce4a..c78bf54b 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -722,7 +722,11 @@ func registerFrontendRoutes(cfg *Config) { } } } - fieldsJSON := templates.JSONArg(services.FilterFieldsPayload(scope)) + fieldsPayload, merr := json.Marshal(services.FilterFieldsPayload(scope)) + if merr != nil { + fieldsPayload = []byte("[]") + } + fieldsJSON := string(fieldsPayload) colData := templates.CollectionData{ ID: collectionID, diff --git a/templates/bookshelf.templ b/templates/bookshelf.templ index 64b3948b..e224aeea 100644 --- a/templates/bookshelf.templ +++ b/templates/bookshelf.templ @@ -180,7 +180,7 @@ templ BookShelf( data-filter-fields={ JSONArg(services.FilterFieldsPayload("books")) } x-effect="filtersOpen === true && ensureReadyRule()" > - @FilterBuilderRows("books") + @FilterBuilderRows("book")
diff --git a/templates/bookshelf_templ.go b/templates/bookshelf_templ.go index 4e5a59ce..1e116d17 100644 --- a/templates/bookshelf_templ.go +++ b/templates/bookshelf_templ.go @@ -424,7 +424,7 @@ func BookShelf( if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = FilterBuilderRows("books").Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = FilterBuilderRows("book").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/collection_rules.templ b/templates/collection_rules.templ index 0f9ee43d..73d9a65c 100644 --- a/templates/collection_rules.templ +++ b/templates/collection_rules.templ @@ -21,7 +21,7 @@ templ CollectionRules(user User, collection CollectionData, fieldsJSON string, t class={ "theme-" + user.Theme } x-data="collectionRules" data-collection-name={ collection.Name } - x-init={ "initCollectionRules('" + collection.ID + "', '" + scope + "', " + JSONArg(treeJSON) + ")" } + x-init={ "initCollectionRules('" + collection.ID + "', '" + scope + "')" } > @Header(user, "/collections")
@@ -52,15 +52,15 @@ templ CollectionRules(user User, collection CollectionData, fieldsJSON string, t root mode IS the collection's match semantics. -->

Filter tree

- @FilterBuilderRows("items") + @FilterBuilderRows("item")
diff --git a/templates/collection_rules_templ.go b/templates/collection_rules_templ.go index 2b2fc3fe..7135cf8e 100644 --- a/templates/collection_rules_templ.go +++ b/templates/collection_rules_templ.go @@ -89,9 +89,9 @@ func CollectionRules(user User, collection CollectionData, fieldsJSON string, tr return templ_7745c5c3_Err } var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("initCollectionRules('" + collection.ID + "', '" + scope + "', " + JSONArg(treeJSON) + ")") + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("initCollectionRules('" + collection.ID + "', '" + scope + "')") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 24, Col: 102} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 24, Col: 75} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) if templ_7745c5c3_Err != nil { @@ -172,9 +172,9 @@ func CollectionRules(user User, collection CollectionData, fieldsJSON string, tr return templ_7745c5c3_Err } var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(JSONArg(fieldsJSON)) + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fieldsJSON) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 55, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 55, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) if templ_7745c5c3_Err != nil { @@ -187,7 +187,7 @@ func CollectionRules(user User, collection CollectionData, fieldsJSON string, tr var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(scope) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 56, Col: 24} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 56, Col: 23} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) if templ_7745c5c3_Err != nil { @@ -198,9 +198,9 @@ func CollectionRules(user User, collection CollectionData, fieldsJSON string, tr return templ_7745c5c3_Err } var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(JSONArg(treeJSON)) + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(treeJSON) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 57, Col: 34} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 57, Col: 25} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) if templ_7745c5c3_Err != nil { @@ -210,7 +210,7 @@ func CollectionRules(user User, collection CollectionData, fieldsJSON string, tr if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = FilterBuilderRows("items").Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = FilterBuilderRows("item").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/filter_builder.templ b/templates/filter_builder.templ index 498dd714..c8385dd2 100644 --- a/templates/filter_builder.templ +++ b/templates/filter_builder.templ @@ -10,7 +10,7 @@ templ FilterBuilderRows(countNoun string) {