fix(templates): align table position and structure across all list pages

Move list partials outside the section element on bags, gear, and brews
pages so they become separate flex children of <main>, matching the
pattern used by roasters, roasts, cafes, and cups. Add mt-6 to each
partial's outer div for consistent spacing.

Standardise brew and gear list partials to match the canonical structure:
empty-state conditional with dashed amber border, <section> wrapper for
the table, and data-star-scope attribute on the outer div.

Update CLAUDE.md to document page template and list partial structure
requirements, preventing future drift.
This commit is contained in:
Jon Seager 2026-02-04 12:04:09 +00:00
parent f4090ba329
commit 3ae3963c5b
No known key found for this signature in database
7 changed files with 73 additions and 28 deletions

View file

@ -424,16 +424,41 @@ The route handler in `application/routes/cafes.rs` accepts either `lat`/`lng` qu
## Table & List Patterns
### Template Structure
### Page Template Structure
Each list page template (`templates/{entity}.html`) places the form and list as **separate siblings** so they become independent flex children of `<main>` (which uses `flex flex-col gap-6`):
```html
<section data-signals:_show-form="false">
<header>...</header>
{% if is_authenticated %}
<div data-show="$_showForm" style="display: none">
<!-- Form -->
</div>
{% endif %}
</section>
{% include "partials/{entity}_list.html" %} {% endblock %}
```
The list include must be **outside** `</section>`, never inside it. Placing it inside removes the flex gap between the form section and the list, causing the table to sit higher on the page.
### List Partial Structure
List partials live in `templates/partials/` (e.g., `roaster_list.html`, `brew_list.html`). Each follows the same structure:
```
```html
{% import "partials/table.html" as table %}
<div id="{entity}-list">
<div id="{entity}-list" class="mt-6" data-star-scope="{entity}">
{% if items.is_empty() && !navigator.has_search() %}
<!-- Empty state (no data, no search active) -->
<div
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/40 px-4 py-6 text-sm text-stone-600"
>
<p class="text-center">
No {entities} recorded yet. Use the form above to add your first {entity}.
</p>
</div>
{% else %}
<section class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm"
{% if items.has_next() %}data-infinite-scroll data-next-url="..." data-target="#{entity}-list"{% endif %}
@ -444,7 +469,7 @@ List partials live in `templates/partials/` (e.g., `roaster_list.html`, `brew_li
<tbody>...</tbody>
</table>
{% if items.is_empty() %}
<!-- "No results match your search" message -->
<div class="p-8 text-center text-stone-500">No {entities} match your search.</div>
{% endif %}
{% call table::pagination_header(items, navigator, "#{entity}-list") %}
{% if items.has_next() %}
@ -455,10 +480,19 @@ List partials live in `templates/partials/` (e.g., `roaster_list.html`, `brew_li
</div>
```
Key points:
- The outer `<div>` 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
Required attributes and elements on every list partial:
| Element | Requirement |
|---------|-------------|
| Outer `<div>` | `id="{entity}-list"`, `class="mt-6"`, `data-star-scope="{entity}"` |
| Empty state | Conditional on `items.is_empty() && !navigator.has_search()`, dashed amber border |
| Table wrapper | `<section>` (not `<div>`) |
| Search header | `{% call table::search_header(...) %}` |
| No-results msg | Inside the `<section>`, shown when search is active but returns nothing |
| Pagination | `{% call table::pagination_header(...) %}` |
| Scroll sentinel | `class="infinite-scroll-sentinel h-4 md:hidden"` |
**Exception**: the bags partial uses a dual-section layout (open-bag cards + history table) and does not follow this pattern exactly.
### Shared Table Macros (`templates/partials/table.html`)

View file

@ -109,6 +109,6 @@
</div>
{% endif %} {% endif %}
<div class="mt-8">{% include "partials/bag_list.html" %}</div>
</section>
{% endblock %}
{% include "partials/bag_list.html" %} {% endblock %}

View file

@ -159,6 +159,6 @@
{% endif %}
{% endif %}
<div class="mt-8">{% include "partials/brew_list.html" %}</div>
</section>
{% endblock %}
{% include "partials/brew_list.html" %} {% endblock %}

View file

@ -91,6 +91,6 @@
</div>
{% endif %}
<div class="mt-8">{% include "partials/gear_list.html" %}</div>
</section>
{% endblock %}
{% include "partials/gear_list.html" %} {% endblock %}

View file

@ -1,6 +1,6 @@
{% import "partials/table.html" as table %}
<div id="bag-list" class="space-y-8" data-star-scope="bags">
<div id="bag-list" class="mt-6 space-y-8" data-star-scope="bags">
{% if !open_bags.is_empty() %}
<section>
<h2 class="text-2xl font-bold mb-4 text-stone-800">Open Bags</h2>

View file

@ -1,7 +1,16 @@
{% import "partials/table.html" as table %}
<div id="brew-list" class="space-y-4" data-star-scope="brews">
<div class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm"
<div id="brew-list" class="mt-6" data-star-scope="brews">
{% if brews.items.is_empty() && !navigator.has_search() %}
<div
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/40 px-4 py-6 text-sm text-stone-600"
>
<p class="text-center">
No brews logged yet. Use the form above to log your first brew.
</p>
</div>
{% else %}
<section class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm"
{% if brews.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(brews.next_page().unwrap())|safe }}" data-target="#brew-list"{% endif %}
>
{% call table::search_header(navigator, "#brew-list") %}
@ -118,7 +127,7 @@
{% if brews.items.is_empty() %}
<div class="p-8 text-center text-stone-500">
No brews logged yet. Start by logging your first cup!
No brews match your search.
</div>
{% endif %}
@ -127,5 +136,6 @@
{% if brews.has_next() %}
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div>
{% endif %}
</div>
</section>
{% endif %}
</div>

View file

@ -1,15 +1,16 @@
{% import "partials/table.html" as table %}
<div id="gear-list">
<div id="gear-list" class="mt-6" data-star-scope="gear">
{% if gear.items.is_empty() && !navigator.has_search() %}
<div class="rounded-lg border border-stone-200 bg-stone-50 p-8 text-center">
<p class="text-stone-500">No gear found.</p>
{% if is_authenticated %}
<p class="mt-2 text-sm text-stone-500">Add your first piece of brewing equipment above.</p>
{% endif %}
<div
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/40 px-4 py-6 text-sm text-stone-600"
>
<p class="text-center">
No gear found. Use the form above to add your first piece of brewing equipment.
</p>
</div>
{% else %}
<div class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm"
<section class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm"
{% if gear.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(gear.next_page().unwrap())|safe }}" data-target="#gear-list"{% endif %}
>
{% call table::search_header(navigator, "#gear-list") %}
@ -71,6 +72,6 @@
{% if gear.has_next() %}
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div>
{% endif %}
</div>
</section>
{% endif %}
</div>