diff --git a/AGENTS.md b/AGENTS.md index 50ab2e5..42c25b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file - Start from product intent and requirements; inspect code only after the target behavior is clear. - Read `REQUIREMENTS.md` before changing behavior. - If behavior changes, update `REQUIREMENTS.md` in the same change. +- If implementation work does not change durable product obligations, acceptance, safety/recovery behavior, or verification duties, say `REQUIREMENT IMPACT: none` in the handoff and why. - Cite relevant requirements in code, tests, or docs as `req: /001`. - Do not add citation-only padding to satisfy tooling; cite only where the requirement constrains the text. - Use requirement tags consistently: stable area tags like `[parser]`, `[auth]`, `[ui]`; temporary planning tags like `[bootstrap]`, `[mvp]`, or `[milestone-1]` only while they are useful. diff --git a/examples/html_examples/src/main.rs b/examples/html_examples/src/main.rs index 93bca91..a58cc7d 100644 --- a/examples/html_examples/src/main.rs +++ b/examples/html_examples/src/main.rs @@ -498,10 +498,12 @@ mod gallery_handlers { ) -> impl IntoEffect { let _ = input.request; let mut count = state.infinite_count.lock().unwrap(); + let first_new = *count + 1; *count += 3; let rows = loaded_rows(*count) .into_iter() - .map(|row| gallery::infinite_row.replace(row)) + .filter(|row| row.id.0 >= first_new as u64) + .map(|row| gallery::infinite_row.append(row)) .collect::>(); let mut effects = rows; effects.push(gallery::infinite_status.set(format!("Showing {} rows", *count))); @@ -538,10 +540,12 @@ mod gallery_handlers { ) -> impl IntoEffect { let _ = input.request; let mut loaded = state.loaded_count.lock().unwrap(); + let first_new = *loaded + 1; *loaded += 2; let rows = loaded_rows(*loaded) .into_iter() - .map(|row| gallery::loaded_row.replace(row)) + .filter(|row| row.id.0 >= first_new as u64) + .map(|row| gallery::loaded_row.append(row)) .collect::>(); let mut effects = rows; effects.push(gallery::load_status.set(format!("Showing {} rows", *loaded))); @@ -554,12 +558,22 @@ mod gallery_handlers { Form(input): Form, ) -> impl IntoEffect { let query = input.query.trim().to_owned(); - *state.query.lock().unwrap() = query.clone(); - let results = search_results_for(&query) + 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 mut effects = previous_results .into_iter() - .map(|result| gallery::search_result.replace(result)) + .map(|result| gallery::search_result.remove(result.id.to_string())) .collect::>(); - let mut effects = results; + effects.extend( + search_results_for(&query) + .into_iter() + .map(|result| gallery::search_result.append(result)), + ); effects.push(gallery::search_status.set(if query.is_empty() { "Showing all results".into() } else { @@ -675,6 +689,17 @@ mod tests { ); } + #[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"); + assert!( + html.contains("data-hemx-revealed=\"true\""), + "lazy-load and infinite-scroll forms must preserve data-hemx-revealed attribute" + ); + } + #[test] fn readme_maps_every_htmx_example_slug() { let readme = include_str!("../README.md"); @@ -782,6 +807,17 @@ mod tests { assert!(lazy.updates_text(gallery::lazy_panel)); assert!(lazy.payload_contains("Lazy content loaded")); + let more = inspect_batch( + InteractionRequest::from(form(gallery::load_more, &[("request", "more")])) + .dispatch_async(handlers(state.clone())) + .await + .unwrap() + .batch, + ); + assert!(more.payload_contains("Loaded row 3")); + assert!(more.payload_contains("Loaded row 4")); + assert!(more.updates_text(gallery::load_status)); + let invalid = inspect_batch( InteractionRequest::from(form(gallery::validate_email, &[("email", "bad")])) .dispatch_async(handlers(state.clone())) @@ -792,6 +828,20 @@ mod tests { assert!(invalid.payload_contains("Use a real email address")); assert!(invalid.updates_text(gallery::email_status)); + let valid = inspect_batch( + InteractionRequest::from(form( + gallery::validate_email, + &[("email", "xyz@example.com")], + )) + .dispatch_async(handlers(state.clone())) + .await + .unwrap() + .batch, + ); + assert!(valid.payload_contains("xyz@example.com is valid")); + assert!(!valid.payload_contains("Use a real email address")); + assert!(valid.updates_text(gallery::email_status)); + let infinite = inspect_batch( InteractionRequest::from(form(gallery::infinite_scroll, &[("request", "more")])) .dispatch_async(handlers(state.clone())) @@ -799,11 +849,8 @@ mod tests { .unwrap() .batch, ); - assert!(infinite.replaces_keyed_html_containing( - gallery::infinite_row, - "6", - "Loaded row 6" - )); + assert!(infinite.payload_contains("Loaded row 4")); + assert!(infinite.payload_contains("Loaded row 6")); let progress = inspect_batch( InteractionRequest::from(form(gallery::tick_progress, &[("request", "tick")])) @@ -841,7 +888,7 @@ mod tests { .unwrap() .batch, ); - assert!(load.replaces_keyed_html_containing(gallery::loaded_row, "4", "Loaded row 4")); + assert!(load.payload_contains("Loaded row 6")); let search = inspect_batch( InteractionRequest::from(form(gallery::search, &[("query", "ga")])) @@ -850,7 +897,24 @@ mod tests { .unwrap() .batch, ); - assert!(search.replaces_keyed_html_containing(gallery::search_result, "3", "Gamma")); + assert!(search.removes_key(gallery::search_result, "1")); + assert!(search.inserts_html_containing(gallery::search_result, "3", "Gamma")); + + let row_save = inspect_batch( + InteractionRequest::from(form( + editable_row::save_row, + &[("id", "1"), ("title", "Write dynamic HTML")], + )) + .dispatch_async(handlers(state.clone())) + .await + .unwrap() + .batch, + ); + assert!(row_save.replaces_keyed_html_containing( + gallery::editable_row, + "1", + "Write dynamic HTML" + )); let delete = inspect_batch( InteractionRequest::from(form(editable_row::delete_row, &[("id", "1")])) diff --git a/hemx-build/src/lib.rs b/hemx-build/src/lib.rs index 75f0ac3..2da0eba 100644 --- a/hemx-build/src/lib.rs +++ b/hemx-build/src/lib.rs @@ -1047,6 +1047,7 @@ fn __hemx_lower_html(html: &str, table: &[(&str, &str, u32)]) -> ::std::string:: "data-hemx-slot" => "data-sid", "data-hemx-handle" => "data-hid", "data-hemx-form" => "data-fid", + "data-hemx-revealed" => "data-hemx-revealed", "data-hemx-atom" => "data-aid", _ => continue, };