From 036307d179be905a5d0ba26b7502a9b489bd11d6 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Mon, 2 Feb 2026 13:50:39 +0000 Subject: [PATCH] docs: document datastar patterns for Claude --- CLAUDE.md | 136 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 1739a62..c4e9617 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,6 +126,142 @@ define_get_command!(GetRoasterCommand, get_roaster, RoasterId, roasters); define_delete_command!(DeleteRoasterCommand, delete_roaster, RoasterId, roasters, "roaster"); ``` +### Datastar Integration + +The web UI uses [Datastar](https://data-star.dev/) for reactive updates without full page reloads. This provides HTMX-style interactions with a declarative API. + +#### Request Detection + +Datastar requests are identified by the `datastar-request: true` header: + +```rust +// application/routes/support.rs +pub fn is_datastar_request(headers: &HeaderMap) -> bool { + headers + .get("datastar-request") + .and_then(|value| value.to_str().ok()) + .map(|value| value.eq_ignore_ascii_case("true")) + .unwrap_or(false) +} +``` + +#### Fragment Rendering + +When a Datastar request is detected, return a fragment instead of a full page: + +```rust +pub(crate) async fn roasters_page(...) -> Result { + let request = query.into_request::(); + + if is_datastar_request(&headers) { + // Datastar request → return fragment only + return render_roaster_list_fragment(state, request, is_authenticated).await; + } + + // Traditional request → return full page with layout + let template = RoastersTemplate { ... }; + render_html(template).map(IntoResponse::into_response) +} +``` + +Fragments are rendered with special headers that tell Datastar where to patch the DOM: + +```rust +// application/routes/support.rs +pub fn render_fragment(template: T, selector: &'static str) -> Result { + let html = render_template(template)?; + let mut response = Html(html).into_response(); + response.headers_mut().insert("datastar-selector", HeaderValue::from_static(selector)); + response.headers_mut().insert("datastar-mode", HeaderValue::from_static("replace")); + Ok(response) +} +``` + +#### Frontend Attributes + +Templates use Datastar attributes for interactivity: + +```html + +
+ + +
+ +
+ + +
+ +
+ + +
+
+``` + +Key attributes: +- `data-signals:name="value"` - Reactive state signals +- `data-show="$signal"` - Conditional visibility +- `data-on:event="expression"` - Event handlers +- `data-ref="name"` - DOM element references +- `@get/@post/@put/@delete(url, options)` - HTTP actions with automatic Datastar headers + +#### URL Generation + +`ListNavigator` generates URLs for pagination and sorting: + +```rust +// presentation/web/views.rs +navigator.page_href(2) // "/roasters?page=2&..." (full page) +navigator.fragment_page_href(2) // "/roasters?page=2&...#roaster-list" (fragment) +navigator.sort_href(key) // "/roasters?sort=name&dir=..." +``` + +#### Flexible Payload Handling + +Handlers accept both JSON and form data via `FlexiblePayload`: + +```rust +pub(crate) async fn create_roaster( + payload: FlexiblePayload, +) -> Result { + let (new_roaster, source) = payload.into_parts(); + + if is_datastar_request(&headers) { + render_fragment(state, request, true).await // Return updated fragment + } else if matches!(source, PayloadSource::Form) { + Ok(Redirect::to(&target).into_response()) // Traditional form redirect + } else { + Ok((StatusCode::CREATED, Json(roaster)).into_response()) // JSON API + } +} +``` + +### Route Handler Macros + +For simple get/delete API handlers, use the macros in `application/routes/macros.rs`: + +```rust +use super::macros::{define_get_handler, define_delete_handler}; + +// GET /api/v1/roasters/:id → returns JSON +define_get_handler!(get_roaster, RoasterId, Roaster, roaster_repo); + +// DELETE /api/v1/roasters/:id → returns fragment for Datastar or 204 for API +define_delete_handler!( + delete_roaster, + RoasterId, + RoasterSortKey, + roaster_repo, + render_roaster_list_fragment +); +``` + ### Error Handling - Domain errors: `RepositoryError` in `domain/errors.rs`