diff --git a/CLAUDE.md b/CLAUDE.md
index 4a055c3..f016f88 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -308,6 +308,93 @@ impl BagRecord {
}
```
+## Table & List Patterns
+
+### Template Structure
+
+List partials live in `templates/partials/` (e.g., `roaster_list.html`, `brew_list.html`). Each follows the same structure:
+
+```
+{% import "partials/table.html" as table %}
+
+
` with `id="{entity}-list"` is the Datastar fragment target for replacements
+- Empty state only shows when there are no items **and** no active search query
+- When a search is active but returns no results, the table section renders with the search bar and a "no matches" message
+
+### Shared Table Macros (`templates/partials/table.html`)
+
+Three macros are available:
+
+- **`search_header(navigator, target_selector)`** — search input with debounced Datastar `@get`, pushes URL state via `history.pushState`
+- **`pagination_header(items, navigator, target_selector)`** — prev/next buttons, page count, rows-per-page selector; hidden on mobile via `pagination-controls hidden md:flex`
+- **`sortable_header(label, key, navigator, target_selector)`** — clickable column header with sort direction arrows
+
+### Responsive Table Pattern
+
+Tables use the `responsive-table` CSS class which converts rows to card-style layout on mobile (`max-width: 767px`). The CSS in `styles.css` hides `
` and uses `data-label` attributes on `` elements to show field labels.
+
+**Desktop**: combined columns with subtext via `hidden md:block`:
+
+```html
+ |
+ {{ brew.roast_name }}
+ {{ brew.roaster_name }}
+ |
+```
+
+**Mobile**: separate `` elements with `md:hidden` for each sub-field:
+
+```html
+ |
+ {{ brew.roaster_name }}
+ |
+```
+
+This keeps the desktop table compact while giving each value its own labelled row in the mobile card view. Conditional sub-fields (e.g., filter paper, city) use `{% if %}` guards around both the desktop subtext and the mobile-only ``.
+
+### Search
+
+Server-side search uses a `q` query parameter. The `ListQuery` struct extracts it and passes it to repository `list()` methods via `SearchFilter`. Repositories apply `LIKE` filtering across entity-specific columns (e.g., name, country, origin).
+
+`ListNavigator` preserves the search term across pagination and sort URL generation via `search_query_base()`.
+
+### Pagination vs Infinite Scroll
+
+- **Desktop** (`md:` breakpoint and above): traditional pagination controls (prev/next, page size selector, result count) via the `pagination_header` macro
+- **Mobile** (below `md:`): pagination controls are hidden (`hidden md:flex`); infinite scroll loads the next page automatically
+
+The infinite scroll sentinel (` `) must always include `md:hidden` to avoid adding unwanted height to the desktop layout. The JavaScript in `base.html` uses `IntersectionObserver` and only activates on mobile via `matchMedia("(max-width: 767px)")`.
+
+When creating sentinels dynamically in JS, use:
+```js
+newSentinel.className = "infinite-scroll-sentinel h-4 md:hidden";
+```
+
## Conventions
1. **Method naming**: Use `order_clause()` for sort query builders (not `sort_clause`)
|