Skip to main content

logicaffeine_web/ui/components/
exercise_block.rs

1//! "Now you try" exercise block for the Syntax Guide.
2//!
3//! The reader writes code (Imperative — graded by exact program output), a
4//! page (Web — graded by the page's own `## Test` blocks) or FOL (Logic —
5//! graded structurally by the Learn grader) and checks it in place. The reference solution never reaches the DOM; the guide's test
6//! suite proves it grades correct before it can ship.
7//!
8//! Pedagogy surface: the kind badge names the exercise's rung on the
9//! worked→completion→free ladder (Sweller/Renkl), hints disclose one at a
10//! time on demand and never spoil (Bloom's corrective tutor), and every
11//! graded result feeds the SM-2 deck so the exercise resurfaces before it
12//! fades — a clean pass without hints schedules further out than a rescued
13//! one.
14
15use super::inline_editor::{InlineEditor, InlineEditorStyle};
16use crate::grader::check_answer;
17use crate::guide_review::record_exercise_result;
18use crate::progress::UserProgress;
19use crate::ui::pages::guide::content::{ExampleMode, ExerciseKind};
20use dioxus::prelude::*;
21use logicaffeine_compile::interpret_for_ui;
22
23#[derive(Props, Clone, PartialEq)]
24pub struct ExerciseBlockProps {
25    pub id: String,
26    pub prompt: String,
27    pub mode: ExampleMode,
28    pub expected: String,
29    pub starter: String,
30    pub kind: ExerciseKind,
31    pub concepts: Vec<String>,
32    pub hints: Vec<String>,
33}
34
35fn record_grade(progress_ctx: Option<Signal<UserProgress>>, exercise_id: &str, quality: u8) {
36    let today = crate::ui::pages::guide::today_string();
37    match progress_ctx {
38        Some(mut progress) => {
39            record_exercise_result(&mut progress.write(), exercise_id, quality, &today);
40        }
41        None => {
42            let mut progress = UserProgress::load();
43            record_exercise_result(&mut progress, exercise_id, quality, &today);
44        }
45    }
46}
47
48#[component]
49pub fn ExerciseBlock(props: ExerciseBlockProps) -> Element {
50    let mut attempt = use_signal(|| props.starter.clone());
51    let mut feedback = use_signal(String::new);
52    let mut passed = use_signal(|| false);
53    let mut hints_shown = use_signal(|| 0usize);
54    let mut fail_recorded = use_signal(|| false);
55
56    let progress_ctx = try_consume_context::<Signal<UserProgress>>();
57
58    let mode = props.mode;
59    let expected = props.expected.clone();
60    let exercise_id = props.id.clone();
61
62    // Quality for the SM-2 deck: a clean unaided pass is Perfect, a hinted
63    // pass is CorrectHesitation; the FIRST failure seeds the card at
64    // Incorrect (later retries inside the same visit don't hammer the
65    // schedule — the eventual pass sets it).
66    let mut on_graded = move |correct: bool| {
67        if correct {
68            let quality = if *hints_shown.read() > 0 { 4 } else { 5 };
69            record_grade(progress_ctx, &exercise_id, quality);
70        } else if !*fail_recorded.read() {
71            fail_recorded.set(true);
72            record_grade(progress_ctx, &exercise_id, 1);
73        }
74    };
75
76    let handle_check = move |_| {
77        let current = attempt.read().clone();
78        match mode {
79            ExampleMode::Theorem => {
80                // No theorem exercises exist yet; grading one would go through
81                // compile_theorem_for_ui + verified.
82                feedback.set("Theorem exercises are proved with largo prove.".to_string());
83            }
84            ExampleMode::Logic => {
85                let grade = check_answer(&current, &expected);
86                passed.set(grade.correct);
87                feedback.set(if grade.correct {
88                    "✓ Correct!".to_string()
89                } else {
90                    format!("Not yet — {}", grade.feedback)
91                });
92                on_graded(grade.correct);
93            }
94            ExampleMode::Web => {
95                #[cfg(not(target_arch = "wasm32"))]
96                {
97                    let verdict = crate::ui::pages::guide::page_verdict(&current);
98                    let correct = verdict.is_ok();
99                    passed.set(correct);
100                    feedback.set(match verdict {
101                        Ok(n) => format!("✓ Correct — {n} test(s) pass."),
102                        Err(why) => format!("Not yet — {}", why.lines().next().unwrap_or("")),
103                    });
104                    on_graded(correct);
105                }
106                // The page test runner is not part of the browser build, so this build does not
107                // grade — and records no grade, since nothing about the reader's answer is known.
108                #[cfg(target_arch = "wasm32")]
109                {
110                    let _ = &current;
111                    feedback.set(
112                        "A page is graded by running its `## Test` blocks, and the test runner is \
113                         not part of the in-browser guide. Save the page in a project and run \
114                         `largo test`."
115                            .to_string(),
116                    );
117                }
118            }
119            ExampleMode::Imperative => {
120                let expected = expected.clone();
121                let mut on_graded = on_graded.clone();
122                spawn(async move {
123                    let r = interpret_for_ui(&current).await;
124                    match r.error {
125                        Some(e) => {
126                            passed.set(false);
127                            feedback.set(format!(
128                                "Your program errored: {}",
129                                e.lines().next().unwrap_or("")
130                            ));
131                            on_graded(false);
132                        }
133                        None => {
134                            let out = r.lines.join("\n");
135                            if out == expected {
136                                passed.set(true);
137                                feedback.set("✓ Correct!".to_string());
138                                on_graded(true);
139                            } else {
140                                passed.set(false);
141                                feedback.set(format!("Not yet — your output was:\n{out}"));
142                                on_graded(false);
143                            }
144                        }
145                    }
146                });
147            }
148        }
149    };
150
151    let rows = attempt.read().lines().count().max(3) + 1;
152    let kind_label = match props.kind {
153        ExerciseKind::Completion => "Fill in the blank",
154        ExerciseKind::Practice => "Practice",
155        ExerciseKind::Challenge => "Challenge",
156    };
157    let shown = *hints_shown.read();
158    let total_hints = props.hints.len();
159
160    rsx! {
161        if mode != ExampleMode::Logic {
162            InlineEditorStyle {}
163        }
164        div {
165            class: "guide-exercise",
166            id: "{props.id}",
167            style: "border: 1px solid rgba(96,165,250,0.25); border-radius: 12px; margin: 20px 0; background: rgba(96,165,250,0.05); overflow: hidden;",
168
169            div {
170                style: "padding: 12px 16px; font-size: 13px; font-weight: 600; color: rgba(229,231,235,0.9); border-bottom: 1px solid rgba(255,255,255,0.08);",
171                span { style: "color: #60a5fa; margin-right: 8px;", "Now you try" }
172                span {
173                    class: "guide-exercise-kind",
174                    style: "display: inline-block; margin-right: 8px; padding: 2px 8px; border-radius: 999px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; background: rgba(96,165,250,0.15); color: #60a5fa; border: 1px solid rgba(96,165,250,0.3);",
175                    "{kind_label}"
176                }
177                "{props.prompt}"
178                if !props.concepts.is_empty() {
179                    div {
180                        class: "guide-exercise-concepts",
181                        style: "margin-top: 6px; font-size: 11px; font-weight: 500; color: rgba(229,231,235,0.45);",
182                        "practices: "
183                        {props.concepts.join(" · ")}
184                    }
185                }
186            }
187            if mode == ExampleMode::Logic {
188                textarea {
189                    style: "width: 100%; padding: 14px 16px; background: transparent; border: none; color: rgba(229,231,235,0.95); font-family: ui-monospace, monospace; font-size: 14px; line-height: 1.6; resize: vertical; outline: none;",
190                    rows: rows as i64,
191                    value: "{attempt}",
192                    oninput: move |evt| attempt.set(evt.value()),
193                    spellcheck: "false",
194                }
195            } else {
196                InlineEditor {
197                    value: attempt.read().clone(),
198                    on_change: move |v| attempt.set(v),
199                    language: mode.language(),
200                }
201            }
202            // Disclosed hints — one at a time, in order (orient, then narrow).
203            if shown > 0 {
204                div {
205                    class: "guide-exercise-hints",
206                    style: "padding: 8px 16px; border-top: 1px solid rgba(255,255,255,0.06); display: flex; flex-direction: column; gap: 6px;",
207                    for hint in props.hints.iter().take(shown) {
208                        div {
209                            style: "font-size: 12px; color: #fbbf24; line-height: 1.5;",
210                            "💡 {hint}"
211                        }
212                    }
213                }
214            }
215            div {
216                style: "display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-top: 1px solid rgba(255,255,255,0.06);",
217                button {
218                    class: "guide-exercise-check",
219                    style: "padding: 7px 14px; border-radius: 8px; border: none; background: linear-gradient(135deg, rgba(96,165,250,0.9), rgba(167,139,250,0.9)); color: #060814; font-size: 12px; font-weight: 700; cursor: pointer;",
220                    onclick: handle_check,
221                    "Check"
222                }
223                if total_hints > 0 && shown < total_hints {
224                    button {
225                        class: "guide-exercise-hint",
226                        style: "padding: 7px 14px; border-radius: 8px; border: 1px solid rgba(251,191,36,0.4); background: none; color: #fbbf24; font-size: 12px; font-weight: 600; cursor: pointer;",
227                        onclick: move |_| { let n = *hints_shown.read(); hints_shown.set(n + 1); },
228                        if shown == 0 { "Hint ({total_hints})" } else { "Another hint ({shown}/{total_hints})" }
229                    }
230                }
231                if !feedback.read().is_empty() {
232                    span {
233                        style: if *passed.read() { "color: #22c55e; font-size: 13px; white-space: pre-wrap;" } else { "color: #fbbf24; font-size: 13px; white-space: pre-wrap;" },
234                        "{feedback}"
235                    }
236                }
237            }
238        }
239    }
240}