diff --git a/CLAUDE.md b/CLAUDE.md
index 1d54bf9..ee96ca9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -177,6 +177,48 @@ socket |> assign(form: to_form(changeset))
- Avoid LiveComponents unless you have a specific need (isolated state, targeted updates)
- Never use deprecated `phx-update="append"` or `phx-update="prepend"`
+## Progressive Enhancement
+
+Build HTML+CSS that works without JS. LiveView layers on top.
+
+**Principle:** If a state change is purely visual (which radio is selected, which panel is shown), handle it in CSS. Don't round-trip to the server for something the browser can do alone.
+
+**Pattern for forms with dynamic sections:**
+
+1. Render ALL possible states in the HTML (e.g. all adapter configs, all option panels)
+2. Use CSS `:has(:checked)` to show the matching section and hide the rest
+3. Namespace field names to avoid clashes (`email[brevo][api_key]`, not `email[api_key]`)
+4. Give the form both `action` (no-JS POST) and `phx-submit` (LiveView)
+5. Add a thin controller to handle the no-JS POST path
+
+```css
+/* Hide all sections by default, show the one matching the checked radio */
+.config-section[data-option] { display: none; }
+form:has(#option-foo:checked) [data-option="foo"] { display: block; }
+```
+
+**JS-only elements:** Wrap in a container with an ID, hide it via `
+```
+
+**HEEx and `")
+end
+```
+
## JS/CSS Guidelines
- Project is fully Tailwind-free — hand-written CSS with `@layer`, native nesting, `oklch()`