feat(workout): complete mobile session flow

Make Workout Copilot behave like a finished phone-first session: rest/next state, progress, undo recovery, finish/export, replayable local log, and host proof controls tucked behind product states.

req: examples/001

req: local/001

req: local/003

req: local/004

req: host/002
This commit is contained in:
slhx agent
2026-06-11 23:15:55 +02:00
parent bf7560b7a8
commit a514237b09
7 changed files with 561 additions and 161 deletions
+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 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.
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 a complete session flow with rest/next state, progress, undo recovery, finish/export, and host boundary results returned through app code, 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.
+3 -2
View File
@@ -1,12 +1,13 @@
# Workout exemplar design
Direction: gym-floor command slate — one heavy next action, thumb-safe command rail, and an inspectable private ledger. req: examples/001
Direction: gym-floor command slate — one heavy next action, thumb-safe command rail, finished-session export, and an inspectable private ledger. req: examples/001
Preserve:
- First viewport shows one obvious action path before logs or host details.
- Controls stay large enough for sweaty one-handed use; forms remain secondary to tap actions.
- Rest/next, progress, undo recovery, and finish/export feel like session states, not framework demos.
- Local truth is visible as a ledger/export, not hidden behind dashboard metrics. req: local/001 req: local/003
- Host capabilities read as a boundary surface, not as app state or a native framework. req: host/002
- Host capabilities read as finish/export product states first, with proof controls tucked behind an explicit panel instead of competing with the user flow. req: host/002
Avoid:
- Dashboard/KPI cards, fake charts, bottom-nav app chrome, glass/gradient hero filler, and hidden client state.
+9 -6
View File
@@ -1,10 +1,10 @@
# Now-first Workout Copilot example
This is the phone-first local-first product exemplar for hemx. It shows a
single useful loop: next action shown, user input accepted, a local command
recorded, a domain event projected, hemx UI updated, and the result exported or
replayed without storing DOM patches or UI update payloads as truth. req: examples/001
req: local/001 req: local/003 req: local/004
complete useful session: next action shown, set completed, rest/next state
entered, progress updated, mistakes undone, workout finished, and the result
exported or replayed without storing DOM patches or UI update payloads as truth.
req: examples/001 req: local/001 req: local/003 req: local/004
## Run
@@ -31,7 +31,9 @@ cargo test -p hemx-workout-example
The E2E test starts the real HTTP binary, loads the page, checks the shared
runtime asset, submits workout interactions, decodes hemx effect responses, and
replays exported events back into a projection. req: test/001 req: examples/001
replays exported events back into a projection. Unit tests cover rest/next,
finish, undo recovery, export replay, and host-result boundaries. req: test/001
req: examples/001
For the full repository gate:
@@ -57,7 +59,8 @@ ship. req: axum_integration/005 req: examples/005
## Boundaries proven
- Core workout logging works without network availability once the page/runtime
is loaded: commands/events/projections are app truth. req: local/001
is loaded: commands/events/projections are app truth, including rest/next,
finish, and undo recovery. req: local/001
- Browser/PWA export uses the host capability contract and the host result
returns through app code before UI effects. req: host/001 req: host/005
- Native-shell-shaped haptic acknowledgment uses the same host call/event path
+442 -109
View File
@@ -23,9 +23,12 @@ pub struct ExercisePlan {
#[derive(Clone, Debug, PartialEq)]
pub enum WorkoutCommand {
CompleteSet,
StartNextSet,
FinishWorkout,
ChangeWeight { kg: f32 },
SkipExercise,
RecordNote { text: String },
UndoLastAction,
}
#[derive(Clone, Debug, PartialEq)]
@@ -36,6 +39,10 @@ pub enum WorkoutEvent {
reps: u8,
kg: f32,
},
RestFinished {
exercise: String,
set: u8,
},
WeightChanged {
exercise: String,
kg: f32,
@@ -46,13 +53,29 @@ pub enum WorkoutEvent {
NoteRecorded {
text: String,
},
WorkoutFinished {
completed_sets: u8,
total_sets: u8,
},
}
#[derive(Clone, Debug, PartialEq)]
pub enum WorkoutPhase {
Ready,
Resting,
ReadyToFinish,
Finished,
}
#[derive(Clone, Debug, PartialEq)]
pub struct WorkoutProjection {
pub current_exercise: usize,
pub completed_sets_for_current: u8,
pub total_completed_sets: u8,
pub phase: WorkoutPhase,
pub next_action: String,
pub primary_action: String,
pub progress: String,
pub exported: bool,
}
@@ -92,35 +115,25 @@ impl AppState {
impl WorkoutState {
pub fn demo() -> Self {
let mut state = Self {
plan: vec![
ExercisePlan {
name: "Goblet squat",
target_sets: 3,
reps: 8,
kg: 24.0,
},
ExercisePlan {
name: "Push-up",
target_sets: 2,
reps: 10,
kg: 0.0,
},
],
plan: demo_plan(),
commands: Vec::new(),
events: Vec::new(),
projection: WorkoutProjection {
current_exercise: 0,
completed_sets_for_current: 0,
next_action: String::new(),
exported: false,
},
projection: WorkoutProjection::start(),
activity: VecDeque::new(),
host_status: "Export waits for an explicit host result.".into(),
host_status: "Ready to keep the session private until you export.".into(),
};
state.refresh_next_action();
state
}
fn primary_command(&self) -> WorkoutCommand {
match self.projection.phase {
WorkoutPhase::Ready => WorkoutCommand::CompleteSet,
WorkoutPhase::Resting => WorkoutCommand::StartNextSet,
WorkoutPhase::ReadyToFinish | WorkoutPhase::Finished => WorkoutCommand::FinishWorkout,
}
}
fn accept(&mut self, command: WorkoutCommand) -> Option<WorkoutEvent> {
// req: local/001 req: local/004
let event = self.validate(&command)?;
@@ -131,52 +144,104 @@ impl WorkoutState {
}
fn validate(&self, command: &WorkoutCommand) -> Option<WorkoutEvent> {
let exercise = self.plan.get(self.projection.current_exercise)?;
match command {
WorkoutCommand::CompleteSet => Some(WorkoutEvent::SetCompleted {
exercise: exercise.name.into(),
set: self.projection.completed_sets_for_current + 1,
reps: exercise.reps,
kg: exercise.kg,
}),
WorkoutCommand::ChangeWeight { kg } => Some(WorkoutEvent::WeightChanged {
exercise: exercise.name.into(),
kg: *kg,
}),
WorkoutCommand::SkipExercise => Some(WorkoutEvent::ExerciseSkipped {
exercise: exercise.name.into(),
}),
WorkoutCommand::CompleteSet if self.projection.phase == WorkoutPhase::Ready => {
let exercise = self.current_exercise()?;
Some(WorkoutEvent::SetCompleted {
exercise: exercise.name.into(),
set: self.projection.completed_sets_for_current + 1,
reps: exercise.reps,
kg: exercise.kg,
})
}
WorkoutCommand::StartNextSet if self.projection.phase == WorkoutPhase::Resting => {
let (exercise, set) = self.next_ready_target()?;
Some(WorkoutEvent::RestFinished {
exercise: exercise.into(),
set,
})
}
WorkoutCommand::FinishWorkout
if self.projection.phase == WorkoutPhase::ReadyToFinish =>
{
Some(WorkoutEvent::WorkoutFinished {
completed_sets: self.projection.total_completed_sets,
total_sets: self.total_target_sets(),
})
}
WorkoutCommand::ChangeWeight { kg } if kg.is_finite() && *kg >= 0.0 => {
let index = self.editable_exercise_index()?;
let exercise = self.plan.get(index)?;
Some(WorkoutEvent::WeightChanged {
exercise: exercise.name.into(),
kg: *kg,
})
}
WorkoutCommand::SkipExercise if self.projection.phase != WorkoutPhase::Finished => {
let exercise = self.current_exercise()?;
Some(WorkoutEvent::ExerciseSkipped {
exercise: exercise.name.into(),
})
}
WorkoutCommand::RecordNote { text } if !text.trim().is_empty() => {
Some(WorkoutEvent::NoteRecorded {
text: text.trim().into(),
})
}
WorkoutCommand::RecordNote { .. } => None,
_ => None,
}
}
fn project(&mut self, event: &WorkoutEvent) {
// req: local/001 req: local/004
match event {
WorkoutEvent::SetCompleted { set, .. } => {
WorkoutEvent::SetCompleted { exercise, set, .. } => {
if let Some(index) = self.exercise_index(exercise) {
self.projection.current_exercise = index;
}
self.projection.completed_sets_for_current = *set;
if let Some(exercise) = self.plan.get(self.projection.current_exercise) {
if *set >= exercise.target_sets {
self.projection.current_exercise += 1;
self.projection.completed_sets_for_current = 0;
}
self.projection.total_completed_sets = self
.projection
.total_completed_sets
.saturating_add(1)
.min(self.total_target_sets());
self.projection.phase =
if self.projection.total_completed_sets >= self.total_target_sets() {
WorkoutPhase::ReadyToFinish
} else {
WorkoutPhase::Resting
};
}
WorkoutEvent::RestFinished { exercise, .. } => {
if let Some(index) = self.exercise_index(exercise) {
self.projection.current_exercise = index;
self.projection.completed_sets_for_current =
completed_sets_for(&self.events, exercise);
}
self.projection.phase = WorkoutPhase::Ready;
}
WorkoutEvent::WeightChanged { exercise, kg } => {
if let Some(index) = self.exercise_index(exercise) {
self.plan[index].kg = *kg;
}
}
WorkoutEvent::WeightChanged { kg, .. } => {
if let Some(exercise) = self.plan.get_mut(self.projection.current_exercise) {
exercise.kg = *kg;
}
}
WorkoutEvent::ExerciseSkipped { .. } => {
self.projection.current_exercise += 1;
WorkoutEvent::ExerciseSkipped { exercise } => {
let next = self
.exercise_index(exercise)
.unwrap_or(self.projection.current_exercise)
+ 1;
self.projection.current_exercise = next;
self.projection.completed_sets_for_current = 0;
self.projection.phase = if next >= self.plan.len() {
WorkoutPhase::ReadyToFinish
} else {
WorkoutPhase::Ready
};
}
WorkoutEvent::NoteRecorded { .. } => {}
WorkoutEvent::WorkoutFinished { .. } => {
self.projection.phase = WorkoutPhase::Finished;
}
}
self.refresh_next_action();
self.activity.push_front(event.summary());
@@ -185,26 +250,126 @@ impl WorkoutState {
}
}
fn undo_last_event(&mut self) -> Option<String> {
// req: local/001 req: local/004
self.commands.push(WorkoutCommand::UndoLastAction);
let removed = self.events.pop()?;
let summary = removed.summary();
self.rebuild_projection();
self.activity.push_front(format!("undid {summary}"));
while self.activity.len() > 5 {
self.activity.pop_back();
}
Some(summary)
}
fn rebuild_projection(&mut self) {
let events = self.events.clone();
let host_status = self.host_status.clone();
let exported = self.projection.exported;
self.plan = demo_plan();
self.projection = WorkoutProjection::start();
self.projection.exported = exported;
self.activity.clear();
self.refresh_next_action();
for event in &events {
self.project(event);
}
self.host_status = host_status;
}
fn refresh_next_action(&mut self) {
self.projection.next_action = self
.plan
.get(self.projection.current_exercise)
.map(|exercise| {
format!(
"Next: {} set {}/{} · {} reps · {} kg",
exercise.name,
self.projection.completed_sets_for_current + 1,
exercise.target_sets,
exercise.reps,
exercise.kg
)
})
.unwrap_or_else(|| "Workout complete · export or replay the event log".into());
self.projection.progress = self.progress_text();
match self.projection.phase {
WorkoutPhase::Ready => {
if let Some(exercise) = self.current_exercise() {
let name = exercise.name;
let target_sets = exercise.target_sets;
let reps = exercise.reps;
let kg = exercise.kg;
self.projection.primary_action = "Complete set".into();
self.projection.next_action = format!(
"Next: {name} set {}/{} · {reps} reps · {kg} kg",
self.projection.completed_sets_for_current + 1,
target_sets,
);
} else {
self.projection.phase = WorkoutPhase::ReadyToFinish;
self.refresh_next_action();
}
}
WorkoutPhase::Resting => {
let target = self
.next_ready_target()
.map(|(exercise, set)| format!("{exercise} set {set}"))
.unwrap_or_else(|| "finish workout".into());
self.projection.primary_action = format!("Start {target}");
self.projection.next_action = format!("Rest 90s · next: {target}");
}
WorkoutPhase::ReadyToFinish => {
self.projection.primary_action = "Finish workout".into();
self.projection.next_action =
"Workout complete · finish and export your session".into();
}
WorkoutPhase::Finished => {
self.projection.primary_action = "Session saved".into();
self.projection.next_action = "Session saved · export or replay anytime".into();
}
}
}
fn current_exercise(&self) -> Option<&ExercisePlan> {
self.plan.get(self.projection.current_exercise)
}
fn editable_exercise_index(&self) -> Option<usize> {
match self.projection.phase {
WorkoutPhase::Finished | WorkoutPhase::ReadyToFinish => None,
WorkoutPhase::Resting
if self.current_exercise().is_some_and(|exercise| {
self.projection.completed_sets_for_current >= exercise.target_sets
}) =>
{
(self.projection.current_exercise + 1 < self.plan.len())
.then_some(self.projection.current_exercise + 1)
}
_ => self
.current_exercise()
.map(|_| self.projection.current_exercise),
}
}
fn exercise_index(&self, name: &str) -> Option<usize> {
self.plan.iter().position(|exercise| exercise.name == name)
}
fn next_ready_target(&self) -> Option<(&'static str, u8)> {
let exercise = self.current_exercise()?;
if self.projection.completed_sets_for_current >= exercise.target_sets {
let next = self.plan.get(self.projection.current_exercise + 1)?;
Some((next.name, 1))
} else {
Some((
exercise.name,
self.projection.completed_sets_for_current + 1,
))
}
}
fn total_target_sets(&self) -> u8 {
self.plan.iter().map(|exercise| exercise.target_sets).sum()
}
fn progress_text(&self) -> String {
let total = self.total_target_sets().max(1);
let done = self.projection.total_completed_sets.min(total);
let percent = u16::from(done) * 100 / u16::from(total);
format!("{done} of {total} sets · {percent}% complete")
}
pub fn event_log_text(&self) -> String {
if self.events.is_empty() {
return "[]".into();
return "No session events yet.".into();
}
self.events
.iter()
@@ -243,6 +408,53 @@ impl WorkoutState {
}
}
impl WorkoutProjection {
fn start() -> Self {
Self {
current_exercise: 0,
completed_sets_for_current: 0,
total_completed_sets: 0,
phase: WorkoutPhase::Ready,
next_action: String::new(),
primary_action: String::new(),
progress: String::new(),
exported: false,
}
}
}
fn demo_plan() -> Vec<ExercisePlan> {
vec![
ExercisePlan {
name: "Goblet squat",
target_sets: 3,
reps: 8,
kg: 24.0,
},
ExercisePlan {
name: "Push-up",
target_sets: 2,
reps: 10,
kg: 0.0,
},
]
}
fn completed_sets_for(events: &[WorkoutEvent], exercise: &str) -> u8 {
events
.iter()
.filter_map(|event| match event {
WorkoutEvent::SetCompleted {
exercise: event_exercise,
set,
..
} if event_exercise == exercise => Some(*set),
_ => None,
})
.max()
.unwrap_or(0)
}
fn export_field(value: &str) -> String {
value.replace(['\t', '\n', '\r'], " ")
}
@@ -258,9 +470,14 @@ impl WorkoutEvent {
} => {
format!("completed {exercise} set {set}: {reps} reps @ {kg} kg")
}
Self::RestFinished { exercise, set } => format!("started {exercise} set {set}"),
Self::WeightChanged { exercise, kg } => format!("changed {exercise} to {kg} kg"),
Self::ExerciseSkipped { exercise } => format!("skipped {exercise}"),
Self::NoteRecorded { text } => format!("note: {text}"),
Self::WorkoutFinished {
completed_sets,
total_sets,
} => format!("finished workout: {completed_sets}/{total_sets} sets complete"),
}
}
@@ -276,6 +493,9 @@ impl WorkoutEvent {
"set_completed\t{}\t{set}\t{reps}\t{kg}",
export_field(exercise)
),
Self::RestFinished { exercise, set } => {
format!("rest_finished\t{}\t{set}", export_field(exercise))
}
Self::WeightChanged { exercise, kg } => {
format!("weight_changed\t{}\t{kg}", export_field(exercise))
}
@@ -283,6 +503,10 @@ impl WorkoutEvent {
format!("exercise_skipped\t{}", export_field(exercise))
}
Self::NoteRecorded { text } => format!("note_recorded\t{}", export_field(text)),
Self::WorkoutFinished {
completed_sets,
total_sets,
} => format!("workout_finished\t{completed_sets}\t{total_sets}"),
}
}
@@ -296,6 +520,10 @@ impl WorkoutEvent {
reps: reps.parse().ok()?,
kg: kg.parse().ok()?,
}),
["rest_finished", exercise, set] => Some(Self::RestFinished {
exercise: (*exercise).into(),
set: set.parse().ok()?,
}),
["weight_changed", exercise, kg] => Some(Self::WeightChanged {
exercise: (*exercise).into(),
kg: kg.parse().ok()?,
@@ -306,6 +534,10 @@ impl WorkoutEvent {
["note_recorded", text] => Some(Self::NoteRecorded {
text: (*text).into(),
}),
["workout_finished", completed_sets, total_sets] => Some(Self::WorkoutFinished {
completed_sets: completed_sets.parse().ok()?,
total_sets: total_sets.parse().ok()?,
}),
_ => None,
}
}
@@ -314,7 +546,10 @@ impl WorkoutEvent {
#[derive(Hemplate)]
pub struct Workout {
pub next_action: String,
pub primary_action: String,
pub status: String,
pub progress: String,
pub recovery_status: String,
pub event_log: String,
pub export_payload: String,
pub host_status: String,
@@ -334,15 +569,23 @@ pub fn page(runtime_src: &'static str, state: &WorkoutState) -> Html {
}
pub fn view(state: &WorkoutState) -> Workout {
let export = state.export_event_log();
Workout {
next_action: state.projection.next_action.clone(),
status: format!(
"{} local commands · {} domain events · projection is the UI source",
state.commands.len(),
state.events.len()
),
primary_action: state.projection.primary_action.clone(),
status: state.projection.progress.clone(),
progress: state.projection.progress.clone(),
recovery_status: state
.activity
.front()
.map(|last| format!("Last action: {last}. Undo is available."))
.unwrap_or_else(|| "Undo appears here after your first session event.".into()),
event_log: state.event_log_text(),
export_payload: state.export_event_log(),
export_payload: if export.is_empty() {
"Complete a set to create a replayable export.".into()
} else {
export
},
host_status: state.host_status.clone(),
}
}
@@ -370,31 +613,47 @@ pub fn interactions(state: AppState) -> impl hemx_axum::DispatchRegistry {
.on(ui::workout::change_weight, change_weight_form)
.on_state(ui::workout::skip_exercise, skip_exercise)
.on(ui::workout::record_note, record_note_form)
.on_state(ui::workout::undo_last_action, undo_last_action)
.on_state(ui::workout::replay_export, replay_export)
.on_state(ui::workout::export_log, export_log)
.on_state(ui::workout::record_share_result, record_share_result)
.on_state(ui::workout::request_native_haptic, request_native_haptic)
.on_state(ui::workout::record_native_haptic_ack, record_native_haptic_ack)
.on_state(
ui::workout::record_native_haptic_ack,
record_native_haptic_ack,
)
.into_registry()
}
fn effects(state: &WorkoutState, status: impl Into<String>) -> impl IntoEffect {
// req: local/001 req: local/004
let view = view(state);
(
ui::workout::next_action.text(&state.projection.next_action),
ui::workout::next_action.text(&view.next_action),
ui::workout::primary_action.text(&view.primary_action),
ui::workout::status.text(status.into()),
ui::workout::event_log.text(state.event_log_text()),
ui::workout::export_payload.text(state.export_event_log()),
ui::workout::host_status.text(&state.host_status),
ui::workout::progress.text(&view.progress),
ui::workout::recovery_status.text(&view.recovery_status),
ui::workout::event_log.text(&view.event_log),
ui::workout::export_payload.text(&view.export_payload),
ui::workout::host_status.text(&view.host_status),
)
}
pub fn complete_set(app: AppState) -> impl IntoEffect {
app.update(|state| {
let event = state.accept(WorkoutCommand::CompleteSet);
let command = state.primary_command();
let event = state.accept(command);
if matches!(event, Some(WorkoutEvent::SetCompleted { .. })) {
state.host_status =
"Set saved locally; native haptic can acknowledge it without owning state.".into();
}
effects(
state,
event.map_or_else(|| "No set available".into(), |event| event.summary()),
event.map_or_else(
|| "Session is already saved; export is ready.".into(),
|event| event.summary(),
),
)
})
}
@@ -404,7 +663,10 @@ pub fn change_weight(app: AppState, kg: f32) -> impl IntoEffect {
let event = state.accept(WorkoutCommand::ChangeWeight { kg });
effects(
state,
event.map_or_else(|| "No exercise available".into(), |event| event.summary()),
event.map_or_else(
|| "Enter a valid weight for the current or next exercise.".into(),
|event| event.summary(),
),
)
})
}
@@ -414,7 +676,23 @@ pub fn skip_exercise(app: AppState) -> impl IntoEffect {
let event = state.accept(WorkoutCommand::SkipExercise);
effects(
state,
event.map_or_else(|| "No exercise available".into(), |event| event.summary()),
event.map_or_else(
|| "No exercise available to skip.".into(),
|event| event.summary(),
),
)
})
}
pub fn undo_last_action(app: AppState) -> impl IntoEffect {
app.update(|state| {
let undone = state.undo_last_event();
effects(
state,
undone.map_or_else(
|| "Nothing to undo yet; complete a set first.".into(),
|summary| format!("Undid: {summary}"),
),
)
})
}
@@ -432,7 +710,10 @@ pub fn record_note(app: AppState, text: impl Into<String>) -> impl IntoEffect {
let event = state.accept(WorkoutCommand::RecordNote { text: text.into() });
effects(
state,
event.map_or_else(|| "Ignored empty note".into(), |event| event.summary()),
event.map_or_else(
|| "Ignored empty note; nothing changed.".into(),
|event| event.summary(),
),
)
})
}
@@ -449,19 +730,23 @@ pub fn replay_export(app: AppState) -> impl IntoEffect {
pub fn export_log(app: AppState) -> impl IntoEffect {
app.update(|state| {
// req: host/001 req: host/004 req: local/003
if state.events.is_empty() {
state.host_status = "Complete a set before opening the share sheet.".into();
return effects(state, "Nothing to export yet");
}
let manifest = CapabilityManifest::new([CapabilityUse::new(
Capability::Share,
CapabilityShape::Request,
)]);
let call = HostCall::Share {
id: HostCallId::new("workout-export"),
payload: HostShareData::text(state.event_log_text()),
payload: HostShareData::text(state.export_event_log()),
};
state.host_status = match manifest.validate_call(&browser_pwa_host_profile(), &call) {
Ok(()) => "Export requested through host share; waiting for host result.".into(),
Err(error) => format!("Host export unavailable: {error}"),
Ok(()) => "Share sheet requested; your local event log remains the source.".into(),
Err(error) => format!("Share unavailable: {error}. Export text stays below."),
};
effects(state, "Export request checked against host manifest")
effects(state, "Share export prepared")
})
}
@@ -475,7 +760,7 @@ pub fn record_share_result(app: AppState) -> impl IntoEffect {
completed: true,
},
);
effects(state, "Browser/PWA host result accepted by app code")
effects(state, "Share result returned through app code")
})
}
@@ -494,9 +779,7 @@ pub fn request_native_haptic(app: AppState) -> impl IntoEffect {
&native_shell_host_profile("ios-android-webview-workout"),
&call,
) {
Ok(()) => {
"Native-shell haptic request accepted; waiting for host acknowledgment.".into()
}
Ok(()) => "Native-shell haptic requested for the saved set.".into(),
Err(error) => format!("Native haptic unavailable: {error}"),
};
effects(state, "Native-shell host call checked against manifest")
@@ -522,17 +805,16 @@ fn apply_host_event(state: &mut WorkoutState, event: HostEvent) {
completed: true, ..
} => {
state.projection.exported = true;
state.host_status =
"Host share completed; event log remains replayable local truth.".into();
state.host_status = "Shared. The replayable event log remains local truth.".into();
}
HostEvent::ShareCompleted {
completed: false, ..
} => {
state.host_status = "Host share cancelled; local event log unchanged.".into();
state.host_status = "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();
"Native haptic acknowledged; workout state stayed app-owned.".into();
}
_ => {
state.host_status = "Host event ignored by app policy.".into();
@@ -562,38 +844,90 @@ mod tests {
app.with_workout(|state| (
state.commands.len(),
state.events.len(),
state.projection.completed_sets_for_current
state.projection.completed_sets_for_current,
state.projection.phase.clone(),
)),
(1, 1, 1)
(1, 1, 1, WorkoutPhase::Resting)
);
assert!(contains_payload_text(
&effects,
"completed Goblet squat set 1"
));
assert!(contains_payload_text(
&effects,
"Next: Goblet squat set 2/3"
));
assert!(contains_payload_text(&effects, "Rest 90s"));
assert!(contains_payload_text(&effects, "Start Goblet squat set 2"));
assert_eq!(
app.with_workout(|state| (state.commands.len(), state.events.len())),
(1, 1)
);
}
#[test]
fn primary_action_moves_from_rest_to_next_set_and_finish() {
// req: examples/001 req: local/001 req: local/004
let app = AppState::demo();
run(|()| complete_set(app.clone()), ());
let rest = run(|()| complete_set(app.clone()), ());
assert!(contains_payload_text(&rest, "started Goblet squat set 2"));
assert_eq!(
app.with_workout(|state| state.projection.phase.clone()),
WorkoutPhase::Ready
);
for _ in 0..7 {
run(|()| complete_set(app.clone()), ());
}
assert_eq!(
app.with_workout(|state| state.projection.phase.clone()),
WorkoutPhase::ReadyToFinish
);
let finished = run(|()| complete_set(app.clone()), ());
assert!(contains_payload_text(
&finished,
"finished workout: 5/5 sets complete"
));
assert_eq!(
app.with_workout(|state| state.projection.phase.clone()),
WorkoutPhase::Finished
);
}
#[test]
fn undo_recovers_last_session_action() {
// req: examples/001 req: local/001 req: local/004
let app = AppState::demo();
run(|()| complete_set(app.clone()), ());
let undone = run(|()| undo_last_action(app.clone()), ());
assert!(contains_payload_text(
&undone,
"Undid: completed Goblet squat set 1"
));
assert_eq!(
app.with_workout(|state| (
state.events.len(),
state.projection.completed_sets_for_current,
state.projection.phase.clone(),
)),
(0, 0, WorkoutPhase::Ready)
);
}
#[test]
fn export_payload_replays_into_projection_before_ui_effects() {
// req: local/001 req: local/003 req: local/004
let app = AppState::demo();
run(|()| complete_set(app.clone()), ());
run(|()| complete_set(app.clone()), ());
run(|()| change_weight(app.clone(), 28.0), ());
let export = app.with_workout(WorkoutState::export_event_log);
assert!(export.contains("set_completed\tGoblet squat\t1\t8\t24"));
assert!(export.contains("rest_finished\tGoblet squat\t2"));
assert!(export.contains("weight_changed\tGoblet squat\t28"));
let replay = run(|()| replay_export(app.clone()), ());
assert!(contains_payload_text(
&replay,
"Replayed 2 exported workout events"
"Replayed 3 exported workout events"
));
assert_eq!(
app.with_workout(|state| {
@@ -601,9 +935,10 @@ mod tests {
state.events.len(),
state.projection.completed_sets_for_current,
state.plan[0].kg,
state.projection.phase.clone(),
)
}),
(2, 1, 28.0)
(3, 1, 28.0, WorkoutPhase::Ready)
);
}
@@ -614,21 +949,18 @@ mod tests {
run(|()| complete_set(app.clone()), ());
let export = run(|()| export_log(app.clone()), ());
assert!(contains_payload_text(
&export,
"Export request checked against host manifest"
));
assert!(contains_payload_text(&export, "waiting for host result"));
assert!(contains_payload_text(&export, "Share export prepared"));
assert!(contains_payload_text(&export, "Share sheet requested"));
let shared = run(|()| record_share_result(app.clone()), ());
assert!(app.with_workout(|state| state.projection.exported));
assert!(contains_payload_text(
&shared,
"Browser/PWA host result accepted by app code"
"Share result returned through app code"
));
assert!(contains_payload_text(
&shared,
"event log remains replayable local truth"
"replayable event log remains local truth"
));
}
@@ -644,7 +976,7 @@ mod tests {
));
assert!(contains_payload_text(
&request,
"waiting for host acknowledgment"
"Native-shell haptic requested"
));
let ack = run(|()| record_native_haptic_ack(app.clone()), ());
@@ -664,8 +996,9 @@ mod tests {
let html = render(&WorkoutState::demo()).to_string();
assert!(html.contains("Now-first Workout Copilot"));
assert!(html.contains("Complete set"));
assert!(html.contains("Undo last action"));
assert!(html.contains("Share export"));
assert!(html.contains("Haptic"));
assert!(html.contains("Host proof panel"));
assert!(!html.contains(&format!("<{}", "script")));
assert!(!html.contains("querySelector"));
}
+69 -25
View File
@@ -5,6 +5,7 @@
color-scheme: dark;
--surface: #11130f;
--surface-2: #171a14;
--surface-3: #202518;
--ink: #f7f3e8;
--muted: #aaa38f;
--line: #35392d;
@@ -46,7 +47,8 @@
letter-spacing: -0.01em;
}
button:focus-visible,
input:focus-visible {
input:focus-visible,
summary:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 3px;
}
@@ -71,6 +73,14 @@
white-space: pre-wrap;
font: 0.86rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
summary {
cursor: pointer;
min-height: var(--tap);
display: flex;
align-items: center;
color: var(--muted);
font-weight: 850;
}
}
@layer composition {
@@ -83,8 +93,8 @@
}
.command-slate,
.action-rail,
.ledger,
.host-boundary {
.finish-card,
.ledger {
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgb(23 26 20 / 0.92);
@@ -94,7 +104,7 @@
@layer blocks {
.command-slate {
min-height: 42svh;
min-height: 44svh;
padding: clamp(1.2rem, 8vw, 2.3rem);
display: flex;
flex-direction: column;
@@ -112,13 +122,26 @@
font-size: 0.78rem;
font-weight: 900;
}
.progress-pill {
width: max-content;
max-width: 100%;
border: 1px solid rgb(215 255 79 / 0.38);
border-radius: 999px;
padding: 0.45rem 0.75rem;
color: var(--accent);
background: rgb(215 255 79 / 0.08);
font-size: 0.82rem;
font-weight: 900;
}
.command-slate h1 {
max-width: 12ch;
font-size: clamp(2.3rem, 14vw, 4.4rem);
max-width: 13ch;
font-size: clamp(2.2rem, 13vw, 4.4rem);
line-height: 0.92;
letter-spacing: -0.08em;
}
.proofline {
.session-status,
.recovery-status,
.finish-card p {
color: var(--muted);
font-weight: 750;
}
@@ -128,7 +151,7 @@
z-index: 1;
padding: 0.7rem;
display: grid;
gap: 0.6rem;
gap: 0.65rem;
}
.primary-action {
color: var(--accent-ink);
@@ -139,6 +162,11 @@
.quiet-action {
color: var(--muted);
}
.secondary-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
}
.micro-form,
.voice-strip {
display: grid;
@@ -156,20 +184,47 @@
text-transform: uppercase;
letter-spacing: 0.08em;
}
.ledger,
.host-boundary {
.finish-card,
.ledger {
padding: 1rem;
display: grid;
gap: 0.85rem;
}
.finish-card {
background:
linear-gradient(135deg, rgb(255 186 82 / 0.12), transparent 20rem),
var(--surface-2);
}
.finish-card h2 {
margin-block-start: 0.2rem;
font-size: 1.35rem;
line-height: 1;
letter-spacing: -0.05em;
}
.share-action {
color: var(--accent-ink);
background: var(--warn);
border-color: var(--warn);
}
.host-proof {
border-top: 1px dashed var(--line);
padding-block-start: 0.35rem;
}
.host-proof p {
margin-block-end: 0.7rem;
font-size: 0.92rem;
}
.host-actions {
display: grid;
gap: 0.55rem;
}
.section-heading {
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
}
.ledger h2,
.host-boundary h2 {
.ledger h2 {
font-size: 1rem;
letter-spacing: -0.03em;
}
@@ -184,17 +239,6 @@
padding: 0.75rem 0.9rem;
background: #0c0e0b;
}
.host-boundary {
border-style: dashed;
}
.host-boundary p {
color: var(--muted);
}
.host-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
}
@media (min-width: 46rem) {
.workout-app {
@@ -206,8 +250,8 @@
.action-rail {
grid-column: 1;
}
.ledger,
.host-boundary {
.finish-card,
.ledger {
grid-column: 2;
}
.action-rail {
+31 -17
View File
@@ -1,23 +1,48 @@
<main data-hemx-root="workout" class="workout-app">
<section class="command-slate" aria-labelledby="next-action-heading">
<p class="eyebrow">Now-first Workout Copilot</p>
<p class="progress-pill" data-hemx-slot="progress">{+ self.progress +}</p>
<h1 id="next-action-heading" data-hemx-slot="next_action">{+ self.next_action +}</h1>
<p class="proofline" data-hemx-slot="status">{+ self.status +}</p>
<p class="session-status" data-hemx-slot="status">{+ self.status +}</p>
</section>
<section class="action-rail" aria-label="Current workout action">
<button class="primary-action" type="button" data-hemx-handle="complete_set">Complete set</button>
<button class="primary-action" type="button" data-hemx-handle="complete_set">
<span data-hemx-slot="primary_action">{+ self.primary_action +}</span>
</button>
<p class="recovery-status" data-hemx-slot="recovery_status">{+ self.recovery_status +}</p>
<div class="secondary-actions">
<button class="quiet-action" type="button" data-hemx-handle="undo_last_action">Undo last action</button>
<button class="quiet-action" type="button" data-hemx-handle="skip_exercise">Skip exercise</button>
</div>
<form class="micro-form" data-hemx-handle="change_weight" method="post">
<label>Weight <input name="kg" value="52.5" inputmode="decimal" aria-label="Weight in kilograms"></label>
<button type="submit">Update</button>
<label>Adjust weight <input name="kg" value="52.5" inputmode="decimal" aria-label="Weight in kilograms"></label>
<button type="submit">Save</button>
</form>
<button class="quiet-action" type="button" data-hemx-handle="skip_exercise">Skip</button>
<form class="voice-strip" data-hemx-handle="record_note" method="post">
<label>Dictate note <input name="text" value="left shoulder felt tight"></label>
<label>Session note <input name="text" value="left shoulder felt tight"></label>
<button type="submit">Record</button>
</form>
</section>
<section class="finish-card" aria-labelledby="finish-heading">
<div>
<p class="eyebrow">Finish & recover</p>
<h2 id="finish-heading">Your session is a replayable local log.</h2>
</div>
<p data-hemx-slot="host_status">{+ self.host_status +}</p>
<button class="share-action" type="button" data-hemx-handle="export_log">Share export</button>
<details class="host-proof">
<summary>Host proof panel</summary>
<p>Development-only host results return through app code before UI effects.</p>
<div class="host-actions">
<button type="button" data-hemx-handle="record_share_result">Mark 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">Mark haptic acknowledged</button>
</div>
</details>
</section>
<section class="ledger" aria-labelledby="event-log-heading">
<div class="section-heading">
<h2 id="event-log-heading">Private event log</h2>
@@ -27,15 +52,4 @@
<h3>Export payload</h3>
<pre data-hemx-slot="export_payload">{+ self.export_payload +}</pre>
</section>
<section class="host-boundary" aria-labelledby="host-heading">
<h2 id="host-heading">Host boundary</h2>
<p data-hemx-slot="host_status">{+ self.host_status +}</p>
<div class="host-actions">
<button type="button" data-hemx-handle="export_log">Share export</button>
<button type="button" data-hemx-handle="record_share_result">Share done</button>
<button type="button" data-hemx-handle="request_native_haptic">Haptic</button>
<button type="button" data-hemx-handle="record_native_haptic_ack">Haptic done</button>
</div>
</section>
</main>
+6 -1
View File
@@ -52,6 +52,8 @@ fn workout_is_e2e_working_over_http() {
assert_eq!(home.status, 200);
assert!(home.header("content-type").contains("text/html"));
assert!(home.text().contains("Now-first Workout Copilot"));
assert!(home.text().contains("Finish & recover"));
assert!(home.text().contains("Undo last action"));
assert!(home.text().contains("Private event log"));
assert!(home.text().contains("Replay export"));
assert!(home
@@ -65,6 +67,7 @@ fn workout_is_e2e_working_over_http() {
assert_eq!(css.status, 200);
assert!(css.header("content-type").contains("text/css"));
assert!(css.text().contains(".command-slate"));
assert!(css.text().contains(".finish-card"));
assert!(css.text().contains("min-height: var(--tap)"));
let runtime = get(&server, runtime_js_path());
@@ -80,7 +83,9 @@ fn workout_is_e2e_working_over_http() {
assert_effect_response(&completed);
let completed_batch = completed.effects();
assert_payload_contains(&completed_batch, "completed Goblet squat set 1");
assert_payload_contains(&completed_batch, "Next: Goblet squat set 2/3");
assert_payload_contains(&completed_batch, "Rest 90s · next: Goblet squat set 2");
assert_payload_contains(&completed_batch, "Start Goblet squat set 2");
assert_payload_contains(&completed_batch, "1 of 5 sets · 20% complete");
let replayed = post(
&server,