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).
This commit is contained in:
John O'Keefe committed 2026-10-30 20:50:55 -04:00
1 parent 7bf5697c71
commit 94985adb5e
9 files changed
+50 -29

No files matched your search

+6 -6
View File
@@ -10,7 +10,7 @@ templ FilterBuilderRows(countNoun string) {
<template x-for="row in builderRows" :key="row.key">
<div>
<template x-if="row.type === 'groupOpen'">
<div :class="row.depth > 0 ? 'fb-nested' : ''" class="flex items-center gap-1.5 flex-wrap py-1" :style="`margin-left:${row.depth * 14}px`">
<div :class="row.depth > 0 ? 'fb-nested' : ''" class="flex items-center gap-1.5 flex-wrap py-1" :style="'margin-left:' + (row.depth * 14) + 'px'">
<span class="text-[11px] font-semibold uppercase tracking-wide" style="color: var(--text-secondary)" x-text="row.depth === 0 ? 'Match' : 'Group'"></span>
<span class="flex-1"></span>
<button type="button" class="fb-mini-btn" @click="addRule(row.path)">+ rule</button>
@@ -19,7 +19,7 @@ templ FilterBuilderRows(countNoun string) {
</div>
</template>
<template x-if="row.type === 'rule'">
<div :class="row.depth > 0 ? 'fb-nested' : ''" class="flex items-center gap-1.5 flex-wrap py-0.5" :style="`margin-left:${(row.depth + 1) * 14}px`">
<div :class="row.depth > 0 ? 'fb-nested' : ''" class="flex items-center gap-1.5 flex-wrap py-0.5" :style="'margin-left:' + ((row.depth + 1) * 14) + 'px'">
<button type="button" class="fb-connective fb-connective-btn" :class="row.chipClickable ? '' : 'fb-chip-static'" x-show="!row.isFirst" x-text="row.chip" @click="if (row.chipClickable) toggleRuleConnective(row.path)" :title="row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip"></button>
<!-- field picker: grouped menu -->
<div class="relative">
@@ -60,8 +60,8 @@ templ FilterBuilderRows(countNoun string) {
</template>
<template x-if="hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')">
<span class="fb-value-cell relative flex items-center gap-1">
<input type="text" class="input fb-value" :value="row.rule.values.join(',')" @input.debounce.250ms="setRuleValue(row.path, 0, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" :placeholder="fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'"/>
<template x-if="fieldMeta(row.rule.field)?.valueKind === 'days'">
<input type="text" class="input fb-value" :value="row.rule.values.join(',')" @input.debounce.250ms="setRuleValue(row.path, 0, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" :placeholder="fieldIsDays(row.rule.field) ? 'days' : 'value'"/>
<template x-if="fieldIsDays(row.rule.field)">
<span class="text-xs whitespace-nowrap" style="color: var(--text-secondary)" x-text="'days'"></span>
</template>
<!-- field-aware value menu: server JSON (the other
@@ -69,7 +69,7 @@ templ FilterBuilderRows(countNoun string) {
Clicks anywhere inside the value cell never close
it — the opening focus-click must not race the
outside handler. -->
<div class="fb-menu fb-menu-value" x-show="suggestionShown(row.key)" @click.outside="if (!$event.target.closest('.fb-value-cell')) suggestionsOpen = ''">
<div class="fb-menu fb-menu-value" x-show="suggestionShown(row.key)" @click.outside="dismissSuggestions($event)">
<template x-for="s in (suggestions[row.key] || [])" :key="s.value">
<button type="button" class="fb-menu-item" @click="pickSuggestion(row.key, row.path, s)">
<span x-text="s.display || s.value"></span>
@@ -86,7 +86,7 @@ templ FilterBuilderRows(countNoun string) {
</div>
</template>
<template x-if="row.type === 'groupClose' && row.depth > 0">
<div class="fb-group-end" :style="`margin-left:${row.depth * 14}px`"></div>
<div class="fb-group-end" :style="'margin-left:' + (row.depth * 14) + 'px'"></div>
</template>
</div>
</template>