diff --git a/AGENTS.md b/AGENTS.md index 5e3e25e..f33dd57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file - Prefer links or pointers to canonical sources over copied lists. - Avoid project trees, architecture maps, generated inventories, current file sizes, issue lists, TODO inventories, and other snapshots that will rot. - Stable commands: `cargo run -p hemx-xtask -- test`, `cargo check --workspace`, `redgate health --strict`. Use the xtask runner for full verification so jobs are capped from local CPU and memory. req: test/004 +- Run the workout product exemplar with `cargo run --bin hemx-workout-example` and open `http://127.0.0.1:3028`; set `HEMX_WORKOUT_ADDR=127.0.0.1:3030` if the default port is busy. req: examples/001 - hemx core stays small: effects, typed ids, registries, and wire schema only. - Routing, auth, sessions, transport, transitions, sync, and storage belong in integration/user crates. - Public examples and beginner APIs should use generated resources and `IntoEffect`, not raw ids or runtime opcodes. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 921aac5..88d5137 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -795,7 +795,7 @@ what a valid business email is. ## examples ### req: examples/001 -001 The repository must contain canonical examples that act as API tests. v0 examples are counter, todo CRUD, form wizard, docs-site page swap, auth action, SSE notifications, and keyed todo list; the workout example is the phone-first local-first product exemplar for commands/events/projections plus host export; local-first kanban is a north-star milestone example. The full techdemo may include an opaque leaf-widget island that communicates through `Effect::event`, without moving island mechanics into hemx core. +001 The repository must contain canonical examples that act as API tests. v0 examples are counter, todo CRUD, form wizard, docs-site page swap, auth action, SSE notifications, and keyed todo list; the workout example is the phone-first local-first product exemplar for commands/events/projections plus host export and must be runnable with the documented cargo command; local-first kanban is a north-star milestone example. The full techdemo may include an opaque leaf-widget island that communicates through `Effect::event`, without moving island mechanics into hemx core. ### req: examples/002 002 Each example must have a maximum ceremony budget. The counter example must fit in under 50 lines of user-authored Rust plus one template. Todo CRUD must fit in under 150 lines excluding model definitions. diff --git a/examples/workout/src/lib.rs b/examples/workout/src/lib.rs index b908017..382bbd8 100644 --- a/examples/workout/src/lib.rs +++ b/examples/workout/src/lib.rs @@ -1,8 +1,8 @@ use hemplate::Hemplate; use hemx::{Html, IntoEffect}; use hemx_host::{ - browser_pwa_host_profile, Capability, CapabilityManifest, CapabilityShape, CapabilityUse, - HostCall, HostCallId, HostEvent, SharePayload, + browser_pwa_host_profile, native_shell_host_profile, Capability, CapabilityManifest, + CapabilityShape, CapabilityUse, HapticPattern, HostCall, HostCallId, HostEvent, SharePayload, }; use std::collections::VecDeque; use std::sync::{Arc, Mutex}; @@ -335,7 +335,44 @@ pub fn record_share_result(app: &AppState) -> impl IntoEffect { completed: true, }, ); - effects(state, "Host result accepted by app code") + effects(state, "Browser/PWA host result accepted by app code") + }) +} + +pub fn request_native_haptic(app: &AppState) -> impl IntoEffect { + app.update(|state| { + // req: host/001 req: host/004 + let manifest = CapabilityManifest::new([CapabilityUse::new( + Capability::Haptics, + CapabilityShape::Fire, + )]); + let call = HostCall::Haptic { + id: HostCallId::new("workout-set-haptic"), + pattern: HapticPattern::Success, + }; + state.host_status = match manifest.validate_call( + &native_shell_host_profile("ios-android-webview-workout"), + &call, + ) { + Ok(()) => { + "Native-shell haptic request accepted; waiting for host acknowledgment.".into() + } + Err(error) => format!("Native haptic unavailable: {error}"), + }; + effects(state, "Native-shell host call checked against manifest") + }) +} + +pub fn record_native_haptic_ack(app: &AppState) -> impl IntoEffect { + app.update(|state| { + // req: host/002 req: host/005 + apply_host_event( + state, + HostEvent::Acknowledged { + id: HostCallId::new("workout-set-haptic"), + }, + ); + effects(state, "Native-shell host result accepted by app code") }) } @@ -353,6 +390,10 @@ fn apply_host_event(state: &mut WorkoutState, event: HostEvent) { } => { state.host_status = "Host share cancelled; local event log unchanged.".into(); } + HostEvent::Acknowledged { id } if id.0 == "workout-set-haptic" => { + state.host_status = + "Native-shell haptic acknowledged; workout state stayed app-owned.".into(); + } _ => { state.host_status = "Host event ignored by app policy.".into(); } @@ -416,7 +457,7 @@ mod tests { assert!(app.with_workout(|state| state.projection.exported)); assert!(contains_payload_text( &shared, - "Host result accepted by app code" + "Browser/PWA host result accepted by app code" )); assert!(contains_payload_text( &shared, @@ -424,13 +465,40 @@ mod tests { )); } + #[test] + fn native_shell_haptic_result_returns_through_app_code() { + // req: host/001 req: host/002 req: host/005 + let app = AppState::demo(); + + let request = run(|()| request_native_haptic(&app), ()); + assert!(contains_payload_text( + &request, + "Native-shell host call checked against manifest" + )); + assert!(contains_payload_text( + &request, + "waiting for host acknowledgment" + )); + + let ack = run(|()| record_native_haptic_ack(&app), ()); + assert!(contains_payload_text( + &ack, + "Native-shell host result accepted by app code" + )); + assert!(contains_payload_text( + &ack, + "workout state stayed app-owned" + )); + } + #[test] fn rendered_page_has_phone_first_controls_without_user_js() { // req: examples/001 req: examples/005 let html = render(&WorkoutState::demo()).to_string(); assert!(html.contains("Now-first Workout Copilot")); assert!(html.contains("Complete set")); - assert!(html.contains("Export via host share")); + assert!(html.contains("Export via browser/PWA share")); + assert!(html.contains("Request native haptic")); assert!(!html.contains(" HandlerRegistry { let state = state.clone(); move |_| workout_app::export_log(&state) }) - .on(workout::record_share_result, move |_| { - workout_app::record_share_result(&state) + .on(workout::record_share_result, { + let state = state.clone(); + move |_| workout_app::record_share_result(&state) + }) + .on(workout::request_native_haptic, { + let state = state.clone(); + move |_| workout_app::request_native_haptic(&state) + }) + .on(workout::record_native_haptic_ack, move |_| { + workout_app::record_native_haptic_ack(&state) }) } diff --git a/examples/workout/templates/workout.heml b/examples/workout/templates/workout.heml index 1512a35..e9e98d2 100644 --- a/examples/workout/templates/workout.heml +++ b/examples/workout/templates/workout.heml @@ -26,7 +26,9 @@

Export host boundary

{+ self.host_status +}

- + + +