diff --git a/AGENTS.md b/AGENTS.md index b161bb7..200abf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file - hemx core stays small: effects, typed ids, registries, and wire schema only; keep features in core only when they fit typed resources plus the closed EffectBatch op set, and treat DOM details as runtime lowering. Workspace crates stay separated, stable-Rust-compatible, and free of kitchen-sink boundaries; new primitives must delete special cases. Public identifiers should flow through typed wrappers over internal `ResourceId`/`ResourceRef`, not special-case opcodes. Wire output lowers symbolic authoring names to compact metadata and postcard/form-encoded envelopes, not JSON. ABI/schema versions and build fingerprints must guard runtime/server compatibility. v0 scope is the checked hypermedia core plus page/runtime/wire/diagnostic/test/axum proof, not optional sync/wasm/query/auth/router breadth. req: v0_scope/001 req: v0_scope/002 req: v0_scope/005 req: laws/001 req: invariant/001 req: invariant/005 req: typed_id/001 req: typed_id/003 req: effect_algebra/001 req: effect_algebra/006 req: wire/001 req: wire/002 req: wire/003 req: wire/004 req: wire/005 req: wire/006 req: abi/001 req: abi/002 req: abi/003 req: abi/004 req: abi/005 req: misc/001 req: misc/002 req: misc/003 req: misc/004 req: misc/005 req: misc/006 req: misc/007 req: misc/008 req: misc/009 req: misc/010 - Routing, auth, sessions, transport, transitions, sync, async data helpers, multipart parsing/uploads, and storage belong in integration/user crates; hemx-axum preserves normal HTTP auth, credentials, CSRF, multipart/browser fallback, and progressive-enhancement semantics rather than defining policy in core. Sync is optional integration state reconciliation over push/transport, not core. req: auth/001 req: auth/002 req: auth/003 req: auth/004 req: auth/005 req: async_data/001 req: async_data/002 req: async_data/003 req: multipart/001 req: multipart/002 req: multipart/003 req: sync/001 req: sync/008 - Public examples and beginner APIs should use templates plus Rust, generated component APIs, resources, view wrappers, render/page helpers, `#[hemx::app]`, plain `#[hemx::handler]` functions, and `IntoEffect`, not atoms, raw ids, selectors, wire formats, runtime opcodes, manual registries, `$OUT_DIR` includes, raw render/lower calls, raw HTML construction, imperative DOM mutation, or raw effect constructors; keep advanced layers out of starters. req: canonical_authoring/001 req: canonical_authoring/004 req: canonical_authoring/006 req: canonical_authoring/010 req: canonical_authoring/015 req: invariant/003 req: dx/001 req: dx/002 req: dx/010 req: component/003 req: component/004 req: view/001 req: view/002 req: html_safety/001 req: html_safety/003 req: html_safety/005 req: public_api/001 req: public_api/002 req: public_api/003 req: public_api/005 req: public_api/006 req: progressive_disclosure/001 req: progressive_disclosure/002 req: progressive_disclosure/003 req: derive_app/001 req: derive_app/002 req: derive_handler/001 req: derive_handler/002 req: derive_handler/003 req: derive_handler/004 req: derive_handler/005 -- Typed partial swaps should stay expressed as generated target plus rendered partial plus swap kind, not selector-driven rerendering or response-side selector retargeting; HTTP, page navigation, push, and island behavior adapt around that loop, and docs should layer new primitives progressively. Navigation is an effect/page-swap concern, not a core router framework, and enhanced links preserve real anchors/history semantics. Push streams carry postcard EffectBatch over server-owned SSE/WebSocket transport and keep `data-hemx-sse` root-scoped/same-origin by default. Preserve keyed/optional scope identity for addressable loop nodes, reconcile filtered keyed collections without clearing retained rows, prefer generated keyed-slot helpers over low-level keyed calls, and route self/row-update diagnostics toward local `data-hemx-slot`/`h-key` targets. req: canonical_authoring/002 req: canonical_authoring/014 req: modes/001 req: scope/001 req: list/001 req: list/002 req: list/003 req: list/004 req: list/005 req: list/006 req: nav/001 req: nav/002 req: nav/003 req: nav/004 req: nav/005 req: push/001 req: push/002 req: push/003 req: push/004 req: push/005 req: push/006 req: push/007 req: progressive_disclosure/004 req: page_swap/001 req: page_swap/002 req: page_swap/003 req: locality/001 req: locality/002 req: target_policy/001 req: target_policy/002 +- Typed partial swaps should stay expressed as generated target plus rendered partial plus swap kind, not selector-driven rerendering or response-side selector retargeting; HTTP, page navigation, push, and island behavior adapt around that loop, and docs should layer new primitives progressively. Navigation is an effect/page-swap concern, not a core router framework; enhanced links and GET forms preserve real URL/history semantics so page state stays reloadable/shareable without a client state graph. Push streams carry postcard EffectBatch over server-owned SSE/WebSocket transport and keep `data-hemx-sse` root-scoped/same-origin by default. Preserve keyed/optional scope identity for addressable loop nodes, reconcile filtered keyed collections without clearing retained rows, prefer generated keyed-slot helpers over low-level keyed calls, and route self/row-update diagnostics toward local `data-hemx-slot`/`h-key` targets. req: canonical_authoring/002 req: canonical_authoring/014 req: modes/001 req: scope/001 req: list/001 req: list/002 req: list/003 req: list/004 req: list/005 req: list/006 req: nav/001 req: nav/002 req: nav/003 req: nav/004 req: nav/005 req: push/001 req: push/002 req: push/003 req: push/004 req: push/005 req: push/006 req: push/007 req: progressive_disclosure/004 req: page_swap/001 req: page_swap/002 req: page_swap/003 req: locality/001 req: locality/002 req: target_policy/001 req: target_policy/002 - `examples/html_examples` is the copy-paste HTML pattern gallery for htmx-style examples; keep exact htmx URL slugs visible while translating behavior to boring `.heml`, generated resources, and server-owned Rust state, not HTMX syntax, selector targeting, or user-authored browser JavaScript. Shared runtime loading and declarative `data-hemx-*` are allowed. Boost containers enhance same-origin descendants only and preserve native external/download/new-tab behavior. req: htmx_equivalents/001 req: htmx_equivalents/003 req: htmx_equivalents/005 req: examples/005 req: examples/007 req: examples/012 req: page_swap/007 req: page_swap/008 - Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter, and `cargo run -p hemx-xtask -- app new --mobile PATH` for the phone-first starter with host capabilities, recovery truth, and release-kit commands; do not treat it as a mobile framework or store-submission bot. req: ceremony/005 req: ceremony/006 req: ceremony/007 - The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..d724a75 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,19 @@ +# hemx plan + +This is a small work surface for explicitly delegated hemx follow-up. `REQUIREMENTS.md` remains the requirement authority; this file only records execution intent. + +## URL-as-state page navigation primitive + +User value: server-first apps can build browse/search/filter pages whose URL is the durable state, so reload, bookmarks, sharing, copy/paste, and back/forward work without app-owned client state or search frameworks. + +State: Done + +Build: +- Document the URL-as-state contract for enhanced GET forms and page swaps: native successful controls serialize into the URL; committed submits push history; high-frequency filter typing may replace history; normal reload/no-JS behavior remains native. req: page_swap/009 +- Convert or add one html_examples browse/search example that uses a plain GET form plus generated page/target helpers to prove query-string state round-trips through server-rendered page state. req: page_swap/009 req: htmx_equivalents/005 +- Add focused runtime/browser smoke that changes a GET query, verifies the URL, reloads or uses back/forward, and observes the same rendered state without hidden client state. req: page_swap/005 req: page_swap/009 +- Keep the primitive subtractive: refuse an omnisearch framework, router, selector include model, client state graph, or domain search API in hemx core; use generated forms, page swap, and `Navigate` only. req: page_swap/003 req: page_swap/009 req: page_swap/010 req: htmx_equivalents/003 + +Blocked by: none. + +Proof: `cargo run -p hemx-xtask -- html-examples-smoke`, focused runtime/page-swap tests, `redgate lint`, and `redgate health --strict` pass with an example showing URL state survives reload/back/forward. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index ca363a3..30e296e 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -225,6 +225,12 @@ client app state framework. ### req: page_swap/008 0 008 Boosted links behave like `data-hemx-nav`; boosted forms behave like hemx form submissions. External links, downloads, new-tab links, and modified-clicks preserve native browser behavior. [north_star] +### req: page_swap/009 +0 009 Enhanced GET navigation treats the URL as shareable page state: native successful GET controls serialize into the request URL, history updates by intent, and reload/bookmark/back/forward reconstruct the same view. [north_star] + +### req: page_swap/010 +0 010 URL-as-state support remains a page/navigation convention, not a router, omnisearch framework, selector include system, or client state graph. [north_star] + --- ## htmx_equivalents diff --git a/examples/html_examples/README.md b/examples/html_examples/README.md index b4104c9..52d78e1 100644 --- a/examples/html_examples/README.md +++ b/examples/html_examples/README.md @@ -13,6 +13,13 @@ cargo run -p hemx-html-examples Open . +The active-search example is URL state rather than an interaction handle: its +GET form serializes the visible `q` control into the page URL, live input uses +`data-hemx-history="replace"`, and the explicit submit button uses +`data-hemx-history="push"`. Reload, bookmark, and browser back/forward therefore +ask the same server route to re-render the filtered gallery instead of restoring +client-owned search state. req: page_swap/009 req: page_swap/010 + ## Pattern matrix Names match the htmx example URL slug exactly, e.g. `modal-custom` from @@ -38,7 +45,7 @@ Status legend: | `lazy-load` | implemented | `data-hemx-revealed` dispatches a generated form once when visible; the server swaps a generated lazy panel. | `gallery.heml`, `gallery_handlers::lazy_load`, `LazyPanel` | | `inline-validation` | implemented | A generated form reports field failure with `validate_email_form.error(...)`, focuses the field, and updates status text. | `templates/gallery.heml`, `gallery_handlers::validate_email` | | `infinite-scroll` | implemented | A revealed sentinel form posts to the same server-owned loading model and replaces generated keyed rows. | `gallery_handlers::infinite_scroll`, `data-hemx-revealed`, `infinite_row` | -| `active-search` | implemented | The search form posts a query; the server derives result rows and reconciles generated keyed partials by removing filtered-out keys, replacing retained keys, and appending newly visible keys. | `gallery_handlers::search`, `SearchResult` | +| `active-search` | implemented | The search form uses GET URL state; the server derives result rows and reconciles generated keyed partials by removing filtered-out keys, replacing retained keys, and appending newly visible keys. | `gallery_handlers::search`, `SearchResult` | | `progress-bar` | implemented | The Tick progress button advances server-owned progress and replaces a generated progress partial with visible percentage text. | `gallery_handlers::tick_progress`, `ProgressMeter` | | `value-select` | implemented | The first select posts a generated form; the server derives and replaces generated option rows for the second select. | `gallery_handlers::choose_category`, `ValueOption` | | `animations` | integration-owned | CSS transitions are presentation policy around generated replacements; hemx should only preserve stable DOM boundaries. | Use keyed partials and app CSS; no core animation framework. | diff --git a/examples/html_examples/src/main.rs b/examples/html_examples/src/main.rs index 246fb09..98fe51c 100644 --- a/examples/html_examples/src/main.rs +++ b/examples/html_examples/src/main.rs @@ -1,5 +1,6 @@ use axum::body::Body; use axum::extract::State; +use axum::http::Uri; use axum::response::{IntoResponse, Response}; use axum::routing::get; use axum::Router; @@ -27,7 +28,6 @@ struct GalleryState { category: Mutex, value: Mutex, reset_status: Mutex, - query: Mutex, } #[derive(Clone)] @@ -125,11 +125,6 @@ struct ResetMessage { message: String, } -#[hemx::form("search")] -struct SearchInput { - query: String, -} - #[derive(Hemplate)] struct AppShell { runtime_src: &'static str, @@ -287,14 +282,18 @@ impl GalleryState { category: Mutex::new("letters".into()), value: Mutex::new("alpha".into()), reset_status: Mutex::new("No message sent yet".into()), - query: Mutex::new(String::new()), } } } -async fn home(State(state): State>, request: PageRequest) -> impl IntoResponse { +async fn home( + State(state): State>, + uri: Uri, + request: PageRequest, +) -> impl IntoResponse { + let query = query_param(uri.query(), "q"); request - .page_html(ui::page(&gallery_view(&state)), shell) + .page_html(ui::page(&gallery_view(&state, &query)), shell) .title("hemx HTML examples") .fingerprint(ui::BUILD_FINGERPRINT) } @@ -329,7 +328,7 @@ fn shell(body: Html) -> Html { }) } -fn gallery_view(state: &GalleryState) -> Gallery { +fn gallery_view(state: &GalleryState, query: &str) -> Gallery { let contacts = state .contacts .lock() @@ -355,7 +354,7 @@ fn gallery_view(state: &GalleryState) -> Gallery { let category = state.category.lock().unwrap().clone(); let value = state.value.lock().unwrap().clone(); let reset_status = state.reset_status.lock().unwrap().clone(); - let query = state.query.lock().unwrap().clone(); + let query = query.trim().to_owned(); let search_results = search_results_for(&query); Gallery { contacts, @@ -458,6 +457,43 @@ fn search_results_for(query: &str) -> Vec { .collect() } +fn query_param(query: Option<&str>, name: &str) -> String { + query + .unwrap_or("") + .split('&') + .filter_map(|pair| pair.split_once('=')) + .find_map(|(key, value)| (key == name).then(|| form_decode(value))) + .unwrap_or_default() +} + +fn form_decode(value: &str) -> String { + let mut bytes = Vec::with_capacity(value.len()); + let mut input = value.as_bytes().iter().copied(); + while let Some(byte) = input.next() { + match byte { + b'+' => bytes.push(b' '), + b'%' => { + let high = input.next().and_then(hex_value); + let low = input.next().and_then(hex_value); + if let (Some(high), Some(low)) = (high, low) { + bytes.push((high << 4) | low); + } + } + byte => bytes.push(byte), + } + } + String::from_utf8_lossy(&bytes).into_owned() +} + +fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + #[hemx::component("gallery")] mod gallery_handlers { use super::*; @@ -586,50 +622,6 @@ mod gallery_handlers { effects.push(gallery::load_status.set(format!("Showing {} rows", *loaded))); effects } - - #[hemx::handler] - async fn search( - State(state): State>, - Form(input): Form, - ) -> impl IntoEffect { - let query = input.query.trim().to_owned(); - let previous_query = { - let mut stored_query = state.query.lock().unwrap(); - let previous_query = stored_query.clone(); - *stored_query = query.clone(); - previous_query - }; - let previous_results = search_results_for(&previous_query); - let current_results = search_results_for(&query); - let previous_keys = previous_results - .iter() - .map(|result| result.id.to_string()) - .collect::>(); - let current_keys = current_results - .iter() - .map(|result| result.id.to_string()) - .collect::>(); - // Keep filtered keyed collections stable: remove filtered-out rows, replace retained - // rows, and append newly visible rows instead of clearing the whole list. req: list/006 - let mut effects = previous_keys - .difference(¤t_keys) - .cloned() - .map(|key| gallery::search_result.remove(key)) - .collect::>(); - effects.extend(current_results.into_iter().map(|result| { - if previous_keys.contains(&result.id.to_string()) { - gallery::search_result.replace(result) - } else { - gallery::search_result.append(result) - } - })); - effects.push(gallery::search_status.set(if query.is_empty() { - "Showing all results".into() - } else { - format!("Results for {query}") - })); - effects - } } #[hemx::component("contact_card")] @@ -731,18 +723,45 @@ mod tests { #[test] fn inline_validation_form_uses_input_event_for_revalidation() { let state = Arc::new(GalleryState::seeded()); - let html = gallery_view(&state).render().expect("render gallery"); + let html = gallery_view(&state, "").render().expect("render gallery"); assert!( html.contains("data-hemx-on=\"input\""), "inline validation form must listen on input events" ); } + #[test] + fn active_search_is_reconstructed_from_url_query() { + // req: page_swap/009 req: page_swap/010 + let state = Arc::new(GalleryState::seeded()); + let html = gallery_view(&state, &query_param(Some("q=ga"), "q")) + .render() + .expect("render gallery"); + assert!(html.contains("name=\"q\" value=\"ga\"")); + assert!(html.contains("Results for ga")); + assert!(html.contains("Gamma")); + assert!(!html.contains("Alpha")); + } + + #[test] + fn active_search_form_marks_live_replace_and_submit_push() { + // req: page_swap/009 + let state = Arc::new(GalleryState::seeded()); + let html = gallery_view(&state, "").render().expect("render gallery"); + assert!(html.contains("method=\"get\"")); + assert!(html.contains("data-hemx-history=\"replace\"")); + assert!(html.contains("data-hemx-on=\"input\"")); + assert!(html.contains("data-hemx-history=\"push\"")); + assert!(!html.contains("data-hemx-handle=\"search\"")); + assert!(!html.contains("data-hemx-form=\"search\"")); + assert!(!html.contains("name=\"__h\"")); + } + #[test] fn revealed_forms_preserve_data_hemx_revealed_attribute() { // req: convention/005 let state = Arc::new(GalleryState::seeded()); - let html = gallery_view(&state).render().expect("render gallery"); + let html = gallery_view(&state, "").render().expect("render gallery"); assert!( html.contains("data-hemx-revealed=\"true\""), "lazy-load and infinite-scroll forms must preserve data-hemx-revealed attribute" @@ -792,7 +811,7 @@ mod tests { #[tokio::test] async fn gallery_covers_core_html_patterns_with_generated_resources() { let state = Arc::new(GalleryState::seeded()); - let html = gallery_view(&state).render().expect("render gallery"); + let html = gallery_view(&state, "").render().expect("render gallery"); assert!(html.contains("data-hemx-root=\"gallery\"")); for slug in [ "click-to-edit", @@ -952,34 +971,6 @@ mod tests { ); assert!(load.payload_contains("Loaded row 6")); - let search = inspect_batch( - InteractionRequest::from(form(gallery::search, &[("query", "ga")])) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - assert!(search.removes_key(gallery::search_result, "1")); - assert!(search.removes_key(gallery::search_result, "2")); - assert!(search.removes_key(gallery::search_result, "4")); - assert!(search.replaces_keyed_html_containing(gallery::search_result, "3", "Gamma")); - - let broader_search = inspect_batch( - InteractionRequest::from(form(gallery::search, &[("query", "a")])) - .dispatch_async(handlers(state.clone())) - .await - .unwrap() - .batch, - ); - assert!(broader_search.inserts_html_containing(gallery::search_result, "1", "Alpha")); - assert!(broader_search.inserts_html_containing(gallery::search_result, "2", "Beta")); - assert!(broader_search.replaces_keyed_html_containing( - gallery::search_result, - "3", - "Gamma" - )); - assert!(broader_search.inserts_html_containing(gallery::search_result, "4", "Delta")); - let row_save = inspect_batch( InteractionRequest::from(form( editable_row::save_row, diff --git a/examples/html_examples/templates/gallery.heml b/examples/html_examples/templates/gallery.heml index 5794a37..fe7e765 100644 --- a/examples/html_examples/templates/gallery.heml +++ b/examples/html_examples/templates/gallery.heml @@ -116,9 +116,9 @@