diff --git a/docs/developer/alpine-patterns.md b/docs/developer/alpine-patterns.md
index be622e05..d4a0f6de 100644
--- a/docs/developer/alpine-patterns.md
+++ b/docs/developer/alpine-patterns.md
@@ -1,92 +1,103 @@
-# Alpine.js Patterns in Bookhoard
-## Book Picker Modal Pattern (SSR + HTMX + Alpine.store)
-For modals that need state persistence across HTMX swaps:
-### TypeScript
+
+
+# Alpine.js patterns in Bookhoard
+
+Bookhoard's Alpine runs the **`@alpinejs/csp` build** — the CSP build, so
+the app's `script-src` can stay a hash allow-list with no
+`'unsafe-eval'`. That single fact dictates most of this page: inline
+JavaScript in HTML is not evaluated, so everything expressive lives in
+registered TypeScript components.
+
+## Rule one: components are registered, not inlined
+
+Every `x-data` references a component registered with `Alpine.data` in
+`web/src` — the attribute value is the component name (optionally with
+arguments), never an object literal:
+
+```typescript
+// web/src/admin.ts
+Alpine.data("admin", () => ({ /* state + methods */ }));
+```
+
+```html
+
+
+```
+
+Arguments work, but the **whole attribute value must be the expression**
+— templ does not interpolate `{ expr }` inside quoted attribute values,
+so `x-data="sidebarShell('{ col.ID }')"` would ship a literal `{ col.ID }`
+to the browser:
+
+```html
+
+```
+
+Free-text arguments that could contain quotes go through
+`templates.JSONArg`, never string concatenation.
+
+## Stores: state that survives htmx swaps
+
+Alpine's reactivity is DOM-local; when htmx swaps a node, its `x-data`
+state dies with it. State that must survive swaps lives in a store —
+`web/src/bookPicker.ts` is the canonical example:
+
```typescript
-// web/src/bookPicker.ts
Alpine.store("bookPicker", {
isOpen: false,
selectedBooks: new Set(),
-
- open() {
- this.isOpen = true;
- this.selectedBooks.clear();
- this.loadBooks();
- },
-
- close() {
- this.isOpen = false;
- this.selectedBooks.clear();
- },
-
- toggleBook(id: string) {
- if (this.selectedBooks.has(id)) {
- this.selectedBooks.delete(id);
- } else {
- this.selectedBooks.add(id);
- }
- // Trigger reactivity
- this.selectedBooks = new Set(this.selectedBooks);
- },
-
- isSelected(id: string): boolean {
- return this.selectedBooks.has(id);
- },
-
- get selectedCount(): number {
- return this.selectedBooks.size;
- }
+ open() { /* … */ },
+ toggleBook(id: string) { /* … */ },
});
-Template
-
+```
+
+```html
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Key Principles
-- ✅ Alpine.store persists state across HTMX DOM swaps
-- ✅ Server returns HTML with checkbox :checked attributes
-- ✅ No client-side rendering or manual DOM manipulation
-- ✅ HTMX handles dynamic content updates
-- ✅ Alpine manages UI state only
-Dropdown Pattern (Local x-data)
+
+```
+
+The server returns HTML (checked checkboxes included); the store tracks
+selection across swaps; nobody rebuilds markup from JSON.
+
+## Local state: plain `x-data` on the smallest element
+
+Ephemeral UI (a dropdown, a panel toggle) needs no store and no
+round-trip:
+
+```html
-
-
-
+
…
-Best Practices
-- ✅ State lives in template (x-data)
-- ✅ UI updates automatically (x-show)
-- ✅ No manual DOM manipulation in TypeScript
-- ✅ Pure business logic only in TypeScript functions
-- ✅ Use Alpine.store for global state
-- ✅ Use local x-data for component state
-- ✅ Use x-init for setup only (no data fetch for SSR pages)
-- ❌ Never use classList in TypeScript
-- ❌ Never use getElementById for show/hide
-- ❌ Never fetch data in x-init (for SSR pages)
+```
+
+## The banned list — all enforced by the CSP build
+
+- **No inline handlers**, in any form: `onclick=`, `onerror=`,
+ templ-brace variants — hash-mode CSP refuses them unconditionally.
+ Rows generated in TypeScript carry `data-*` attributes and bind
+ listeners after swap (see `custom-section-builder.ts`).
+- **No `hx-on*` attributes and no `js:`-prefixed `hx-vals`** — both
+ evaluate strings, which the CSP build cannot do. Plain JSON `hx-vals`
+ only (`templates/utils.go` has the helper).
+- **No inline `