feat(examples): add native-shell workout host path

Extend the workout exemplar with a native-shell haptic request and acknowledgment path that validates the host manifest and returns through app code without owning workout state.

req: host/001

req: host/002

req: host/004

req: host/005

req: examples/001
This commit is contained in:
slhx agent
2026-06-11 20:43:14 +02:00
parent 19bf47ec72
commit e76a564add
5 changed files with 88 additions and 9 deletions
+1
View File
@@ -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.
+1 -1
View File
@@ -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.
+73 -5
View File
@@ -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("<script"));
assert!(!html.contains("querySelector"));
}
+10 -2
View File
@@ -70,8 +70,16 @@ fn registry(state: AppState) -> 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)
})
}
+3 -1
View File
@@ -26,7 +26,9 @@
<section class="host">
<h2>Export host boundary</h2>
<p data-hemx-slot="host_status">{+ self.host_status +}</p>
<button type="button" data-hemx-handle="export_log">Export via host share</button>
<button type="button" data-hemx-handle="export_log">Export via browser/PWA share</button>
<button type="button" data-hemx-handle="record_share_result">Record share completed</button>
<button type="button" data-hemx-handle="request_native_haptic">Request native haptic</button>
<button type="button" data-hemx-handle="record_native_haptic_ack">Record haptic acknowledged</button>
</section>
</main>