Skip to main content

logicaffeine_web/ui/pages/guide/
mod.rs

1//! Programmer's Guide page.
2//!
3//! A beautiful, interactive guide to the LOGOS programming language with:
4//! - 22 sections, from the basics to the full language
5//! - Sticky sidebar navigation
6//! - Interactive code examples with Run/Copy/Reset
7//! - Dual mode: Logic (FOL output) and Imperative (WASM execution)
8//! - Every example, exercise and quoted snippet in the Studio's editor, with the language server
9
10pub mod content;
11
12use crate::progress::UserProgress;
13use crate::ui::components::code_editor::Language;
14use crate::ui::components::exercise_block::ExerciseBlock;
15use crate::ui::components::footer::Footer;
16use crate::ui::components::guide_code_block::GuideCodeBlock;
17use crate::ui::components::guide_sidebar::{GuideSidebar, SectionInfo};
18use crate::ui::components::guide_snippet::GuideSnippet;
19use crate::ui::components::main_nav::{ActivePage, MainNav};
20use crate::ui::components::misconception_box::MisconceptionBox;
21use crate::ui::components::recall_block::RecallBlock;
22use crate::ui::inline_markup::inline_markup_html;
23use crate::ui::markdown_blocks::{blocks, Block};
24use crate::ui::seo::{
25    breadcrumb_schema, organization_schema, pages as seo_pages, tech_article_schema,
26    BreadcrumbItem, JsonLdMultiple, PageHead,
27};
28use content::SECTIONS;
29use dioxus::prelude::*;
30#[cfg(all(feature = "split", target_arch = "wasm32"))]
31use dioxus::wasm_split;
32
33/// Today as `YYYY-MM-DD` — the date the SM-2 deck schedules against.
34pub fn today_string() -> String {
35    #[cfg(target_arch = "wasm32")]
36    {
37        use wasm_bindgen::prelude::*;
38
39        #[wasm_bindgen]
40        extern "C" {
41            #[wasm_bindgen(js_namespace = Date, js_name = now)]
42            fn date_now() -> f64;
43        }
44
45        let ms = date_now() as i64;
46        let days_since_epoch = ms / 86400000;
47        let year = 1970 + (days_since_epoch / 365) as i32;
48        let day_of_year = (days_since_epoch % 365) as i32;
49        let month = (day_of_year / 30).min(11) + 1;
50        let day = (day_of_year % 30) + 1;
51        format!("{:04}-{:02}-{:02}", year, month, day)
52    }
53
54    #[cfg(not(target_arch = "wasm32"))]
55    {
56        "2025-01-01".to_string()
57    }
58}
59
60/// The canonical rendering of a theorem run — shared by the Run button and
61/// the test harness, so the locked expected output IS what the reader sees.
62pub fn theorem_verdict(r: &logicaffeine_compile::TheoremCompileResult) -> Result<String, String> {
63    if let Some(e) = &r.error {
64        return Err(e.clone());
65    }
66    let mut out = if r.verified {
67        format!("Proved: {} — kernel-certified.", r.name)
68    } else {
69        format!("Not proved: {}.", r.name)
70    };
71    if let Some(goal) = &r.goal_string {
72        out.push_str(&format!("\nGoal: {goal}"));
73    }
74    Ok(out)
75}
76
77/// The verdict on a web page's own `## Test` blocks — shared by the web exercise grader and the
78/// test harness, so what the reader is told is what the suite checked. `Ok` carries how many tests
79/// passed; a page with no test, or with any failing one, is an `Err` naming what went wrong.
80///
81/// Host-only because the test runner it reads (`logicaffeine_compile::testrun`) is: the runner
82/// seals each test's world with the in-memory filesystem, which `logicaffeine_system` builds for
83/// native targets only.
84#[cfg(not(target_arch = "wasm32"))]
85pub fn page_verdict(source: &str) -> Result<usize, String> {
86    use logicaffeine_compile::testrun::{run_tests, TestConfig, Verdict};
87    let report = run_tests(source, &TestConfig::default())?;
88    if report.outcomes.is_empty() {
89        return Err(
90            "the page has no `## Test` — a test is how a page proves what it does".to_string(),
91        );
92    }
93    let failed: Vec<String> = report
94        .outcomes
95        .iter()
96        .filter(|o| o.verdict != Verdict::Passed)
97        .map(|o| format!("`{}`: {}", o.name, o.failures.join("; ")))
98        .collect();
99    if failed.is_empty() {
100        Ok(report.passed)
101    } else {
102        Err(failed.join("\n"))
103    }
104}
105
106/// Case-insensitive full-text search over section titles, prose, hooks,
107/// objectives, misconceptions, recap questions, and example labels/code, in
108/// display order. Blank queries match nothing (no noise).
109pub fn search_sections(query: &str) -> Vec<&'static content::Section> {
110    let q = query.trim().to_lowercase();
111    if q.is_empty() {
112        return Vec::new();
113    }
114    SECTIONS
115        .iter()
116        .filter(|s| {
117            s.title.to_lowercase().contains(&q)
118                || s.content.to_lowercase().contains(&q)
119                || s.hook.to_lowercase().contains(&q)
120                || s.objectives.iter().any(|o| o.to_lowercase().contains(&q))
121                || s.misconceptions.iter().any(|m| {
122                    m.expectation.to_lowercase().contains(&q)
123                        || m.reality.to_lowercase().contains(&q)
124                })
125                || s.recap.iter().any(|r| {
126                    r.question.to_lowercase().contains(&q) || r.answer.to_lowercase().contains(&q)
127                })
128                || s.examples.iter().any(|e| {
129                    e.label.to_lowercase().contains(&q) || e.code.to_lowercase().contains(&q)
130                })
131        })
132        .collect()
133}
134
135const GUIDE_STYLE: &str = r#"
136.guide-page {
137    min-height: 100vh;
138    color: var(--text-primary);
139    background:
140        radial-gradient(1200px 600px at 50% -120px, rgba(167,139,250,0.14), transparent 60%),
141        radial-gradient(900px 500px at 15% 30%, rgba(96,165,250,0.14), transparent 60%),
142        radial-gradient(800px 450px at 90% 45%, rgba(34,197,94,0.08), transparent 62%),
143        linear-gradient(180deg, #070a12, #0b1022 55%, #070a12);
144    font-family: var(--font-sans);
145}
146
147/* Navigation - now handled by MainNav component */
148
149/* Hero */
150.guide-hero {
151    max-width: 1280px;
152    margin: 0 auto;
153    padding: 60px var(--spacing-xl) 40px;
154}
155
156.guide-hero h1 {
157    font-size: var(--font-display-lg);
158    font-weight: 900;
159    letter-spacing: -1.5px;
160    line-height: 1.1;
161    background: linear-gradient(180deg, #ffffff 0%, rgba(229,231,235,0.78) 65%, rgba(229,231,235,0.62) 100%);
162    -webkit-background-clip: text;
163    -webkit-text-fill-color: transparent;
164    margin: 0 0 var(--spacing-lg);
165}
166
167.guide-hero p {
168    font-size: var(--font-body-lg);
169    color: var(--text-secondary);
170    max-width: 600px;
171    line-height: 1.6;
172    margin: 0;
173}
174
175.guide-hero-badge {
176    display: inline-flex;
177    align-items: center;
178    gap: var(--spacing-sm);
179    padding: var(--spacing-sm) 14px;
180    border-radius: var(--radius-full);
181    background: rgba(255,255,255,0.06);
182    border: 1px solid rgba(255,255,255,0.10);
183    font-size: var(--font-caption-md);
184    font-weight: 600;
185    color: var(--text-primary);
186    margin-bottom: var(--spacing-xl);
187}
188
189.guide-hero-badge .dot {
190    width: 8px;
191    height: 8px;
192    border-radius: 50%;
193    background: var(--color-success);
194    box-shadow: 0 0 0 4px rgba(34,197,94,0.15);
195}
196
197/* Layout */
198.guide-layout {
199    max-width: 1280px;
200    margin: 0 auto;
201    display: flex;
202    gap: 48px;
203    padding: 0 var(--spacing-xl) 80px;
204}
205
206/* Main content */
207.guide-content {
208    flex: 1;
209    min-width: 0;
210    max-width: 800px;
211    padding-right: var(--spacing-lg);
212}
213
214@media (max-width: 1024px) {
215    .guide-content {
216        padding-right: 0;
217    }
218}
219
220/* Section styling */
221.guide-section {
222    margin-bottom: 64px;
223    scroll-margin-top: 100px;
224}
225
226.guide-section h2 {
227    font-size: var(--font-display-md);
228    font-weight: 800;
229    letter-spacing: -0.8px;
230    line-height: 1.2;
231    background: linear-gradient(180deg, #ffffff 0%, rgba(229,231,235,0.85) 100%);
232    -webkit-background-clip: text;
233    -webkit-text-fill-color: transparent;
234    margin: 0 0 var(--spacing-xl);
235    padding-bottom: var(--spacing-lg);
236    border-bottom: 1px solid rgba(255,255,255,0.08);
237}
238
239.guide-section h3 {
240    font-size: var(--font-heading-sm);
241    font-weight: 700;
242    color: var(--text-primary);
243    margin: var(--spacing-xxl) 0 var(--spacing-lg);
244}
245
246.guide-section p {
247    color: var(--text-secondary);
248    font-size: var(--font-body-sm);
249    line-height: 1.75;
250    margin: 0 0 var(--spacing-lg);
251}
252
253.guide-section ul,
254.guide-section ol {
255    color: var(--text-secondary);
256    font-size: var(--font-body-sm);
257    line-height: 1.75;
258    padding-left: var(--spacing-xl);
259    margin: 0 0 var(--spacing-lg);
260}
261
262.guide-section li {
263    margin-bottom: var(--spacing-sm);
264}
265
266.guide-section hr {
267    border: 0;
268    border-top: 1px solid rgba(255,255,255,0.08);
269    margin: var(--spacing-xxl) 0;
270}
271
272.guide-section code {
273    font-family: var(--font-mono);
274    background: rgba(255,255,255,0.08);
275    padding: 3px 7px;
276    border-radius: var(--radius-sm);
277    font-size: 0.9em;
278    color: var(--color-accent-purple);
279}
280
281/* Plain fences (install commands, config) — copyable, one command per block. A class of their
282   own, apart from the live example component's. */
283.guide-section .guide-fence {
284    position: relative;
285    margin: 0 0 var(--spacing-lg);
286}
287
288.guide-section .guide-fence pre {
289    font-family: var(--font-mono);
290    background: rgba(255,255,255,0.05);
291    border: 1px solid rgba(255,255,255,0.08);
292    border-radius: var(--radius-lg);
293    padding: var(--spacing-lg);
294    padding-right: 72px;
295    overflow-x: auto;
296    font-size: 0.9em;
297    line-height: 1.6;
298    margin: 0;
299}
300
301.guide-section .guide-fence code {
302    background: none;
303    padding: 0;
304    color: var(--text-primary);
305}
306
307.guide-section .guide-code-copy {
308    position: absolute;
309    top: 10px;
310    right: 10px;
311    font-size: 12px;
312    color: var(--text-secondary);
313    background: rgba(255,255,255,0.06);
314    border: 1px solid rgba(255,255,255,0.1);
315    border-radius: var(--radius-sm);
316    padding: 3px 10px;
317    cursor: pointer;
318}
319
320.guide-section .guide-code-copy:hover {
321    color: var(--text-primary);
322    background: rgba(255,255,255,0.12);
323}
324
325/* LOGOS quoted in the prose: the shared editor, read-only, on a command block's surface */
326.guide-section .guide-snippet {
327    position: relative;
328    margin: 0 0 var(--spacing-lg);
329    background: rgba(255,255,255,0.05);
330    border: 1px solid rgba(255,255,255,0.08);
331    border-radius: var(--radius-lg);
332}
333
334.guide-section .guide-snippet .code-editor-highlight,
335.guide-section .guide-snippet .code-editor-textarea {
336    padding-right: 72px;
337}
338
339/* Above the editor's text area, which lies over the whole snippet */
340.guide-section .guide-snippet .guide-code-copy {
341    z-index: 3;
342}
343
344.guide-section strong {
345    color: var(--text-primary);
346    font-weight: 600;
347}
348
349/* Tables - wrapped in scrollable container */
350.guide-section .table-wrapper {
351    width: 100%;
352    overflow-x: auto;
353    margin: var(--spacing-xl) 0;
354    border-radius: var(--radius-lg);
355    border: 1px solid rgba(255,255,255,0.08);
356    -webkit-overflow-scrolling: touch;
357}
358
359.guide-section table {
360    width: 100%;
361    min-width: 400px;
362    border-collapse: collapse;
363    font-size: var(--font-body-md);
364}
365
366.guide-section th {
367    text-align: left;
368    padding: 14px var(--spacing-lg);
369    background: rgba(255,255,255,0.05);
370    color: var(--text-primary);
371    font-weight: 600;
372    border-bottom: 1px solid rgba(255,255,255,0.08);
373    white-space: nowrap;
374}
375
376.guide-section td {
377    padding: var(--spacing-md) var(--spacing-lg);
378    color: var(--text-secondary);
379    border-bottom: 1px solid rgba(255,255,255,0.05);
380}
381
382.guide-section tr:last-child td {
383    border-bottom: none;
384}
385
386.guide-section tr:hover td {
387    background: rgba(255,255,255,0.02);
388}
389
390/* Collapsible examples container */
391.guide-examples-collapsible {
392    margin-top: var(--spacing-xl);
393    border: 1px solid rgba(255,255,255,0.08);
394    border-radius: var(--radius-lg);
395    overflow: hidden;
396}
397
398.guide-examples-header {
399    display: flex;
400    align-items: center;
401    justify-content: space-between;
402    padding: var(--spacing-lg) var(--spacing-xl);
403    background: rgba(255,255,255,0.03);
404    cursor: pointer;
405    user-select: none;
406    transition: background 0.15s ease;
407}
408
409.guide-examples-header:hover {
410    background: rgba(255,255,255,0.05);
411}
412
413.guide-examples-header-left {
414    display: flex;
415    align-items: center;
416    gap: var(--spacing-md);
417}
418
419.guide-examples-title {
420    font-size: var(--font-body-md);
421    font-weight: 600;
422    color: var(--text-primary);
423}
424
425.guide-examples-count {
426    font-size: var(--font-caption-md);
427    color: var(--text-tertiary);
428    background: rgba(255,255,255,0.06);
429    padding: 4px 10px;
430    border-radius: var(--radius-full);
431}
432
433.guide-examples-chevron {
434    font-size: 12px;
435    color: var(--text-tertiary);
436    transition: transform 0.2s ease;
437}
438
439.guide-examples-collapsible.expanded .guide-examples-chevron {
440    transform: rotate(90deg);
441}
442
443.guide-examples-content {
444    max-height: 0;
445    overflow: hidden;
446    transition: max-height 0.3s ease;
447}
448
449.guide-examples-collapsible.expanded .guide-examples-content {
450    max-height: 10000px;
451}
452
453.guide-examples-inner {
454    padding: var(--spacing-lg);
455    display: flex;
456    flex-direction: column;
457    gap: var(--spacing-lg);
458}
459
460/* Part dividers */
461.guide-part-divider {
462    margin: 80px 0 48px;
463    padding: var(--spacing-xl) 0;
464    border-top: 1px solid rgba(255,255,255,0.08);
465}
466
467.guide-part-divider h2 {
468    font-size: var(--font-body-md);
469    font-weight: 700;
470    text-transform: uppercase;
471    letter-spacing: 1.5px;
472    color: var(--text-tertiary);
473    margin: 0;
474    background: none;
475    -webkit-text-fill-color: currentColor;
476    border-bottom: none;
477    padding-bottom: 0;
478}
479
480/* Section number */
481.section-number {
482    display: inline-block;
483    font-size: var(--font-body-md);
484    font-weight: 700;
485    color: var(--color-accent-purple);
486    margin-right: var(--spacing-sm);
487    opacity: 0.8;
488}
489
490/* Examples container */
491.guide-examples {
492    margin-top: var(--spacing-xl);
493    display: flex;
494    flex-direction: column;
495    gap: var(--spacing-lg);
496}
497
498/* Responsive */
499@media (max-width: 1024px) {
500    .guide-layout {
501        flex-direction: column;
502    }
503
504    .guide-hero h1 {
505        font-size: var(--font-display-md);
506    }
507
508    .guide-hero {
509        padding: 40px var(--spacing-xl) var(--spacing-xxl);
510    }
511}
512
513@media (max-width: 640px) {
514    .guide-hero h1 {
515        font-size: var(--font-heading-lg);
516    }
517
518    .guide-hero p {
519        font-size: var(--font-body-md);
520    }
521
522    .guide-section h2 {
523        font-size: var(--font-heading-lg);
524    }
525}
526
527.guide-community-cta {
528    text-align: center;
529    padding: var(--spacing-xxl) var(--spacing-xl);
530    margin-top: var(--spacing-xxl);
531    border-top: 1px solid rgba(255,255,255,0.06);
532}
533
534.guide-community-cta p {
535    font-size: var(--font-body-lg);
536    color: var(--text-secondary);
537    margin-bottom: var(--spacing-md);
538}
539
540.guide-community-cta a {
541    display: inline-block;
542    padding: 8px 20px;
543    border-radius: var(--radius-full);
544    background: rgba(167,139,250,0.06);
545    border: 1px solid rgba(167,139,250,0.3);
546    color: var(--color-accent-purple);
547    font-weight: 600;
548    font-size: var(--font-body-md);
549    text-decoration: none;
550    transition: background 0.2s, border-color 0.2s;
551}
552
553.guide-community-cta a:hover {
554    background: rgba(167,139,250,0.14);
555    border-color: rgba(167,139,250,0.5);
556}
557
558/* The hook: the headache before the aspirin */
559.guide-hook {
560    font-size: var(--font-body-md);
561    color: var(--text-primary);
562    line-height: 1.7;
563    padding: var(--spacing-lg) var(--spacing-xl);
564    margin: 0 0 var(--spacing-xl);
565    border-left: 3px solid var(--color-accent-purple);
566    border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
567    background: rgba(167,139,250,0.07);
568}
569
570/* "After this section you can" */
571.guide-objectives {
572    margin: 0 0 var(--spacing-xl);
573    padding: var(--spacing-lg) var(--spacing-xl);
574    border: 1px solid rgba(255,255,255,0.08);
575    border-radius: var(--radius-md);
576    background: rgba(255,255,255,0.03);
577}
578
579.guide-objectives-title {
580    font-size: 11px;
581    font-weight: 700;
582    text-transform: uppercase;
583    letter-spacing: 0.6px;
584    color: rgba(229,231,235,0.55);
585    margin-bottom: var(--spacing-sm);
586}
587
588.guide-objectives ul {
589    margin: 0;
590    padding-left: var(--spacing-xl);
591}
592
593.guide-objectives li {
594    color: var(--text-secondary);
595    font-size: var(--font-body-sm);
596    margin-bottom: 4px;
597}
598
599/* Blockquote callouts */
600.guide-section blockquote {
601    margin: 0 0 var(--spacing-lg);
602    padding: var(--spacing-md) var(--spacing-lg);
603    border-left: 3px solid rgba(255,255,255,0.2);
604    border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
605    background: rgba(255,255,255,0.03);
606    color: var(--text-secondary);
607    font-size: var(--font-body-sm);
608    line-height: 1.7;
609}
610
611.guide-section blockquote.guide-mental-model {
612    border-left-color: #22c55e;
613    background: rgba(34,197,94,0.06);
614}
615
616.guide-section blockquote.guide-look-back {
617    border-left-color: #60a5fa;
618    background: rgba(96,165,250,0.06);
619}
620"#;
621
622// `lazy`: with the `split` feature + dx `--wasm-split`, this page's body (and the
623// guide content strings only it references) moves into the lazily-fetched chunk.
624#[component(lazy)]
625pub fn Guide() -> Element {
626    let mut active_section = use_signal(|| "introduction".to_string());
627    let mut search_query = use_signal(String::new);
628    // Example groups default to EXPANDED; this set holds the ids the reader
629    // (or the collapse-all control) has closed.
630    let mut collapsed_groups = use_signal(std::collections::HashSet::<String>::new);
631    let mut completed = use_signal(Vec::<String>::new);
632    // The SM-2 progress store, shared with every exercise block and recall
633    // card on the page (they write through it, the mastery ring reads it).
634    let progress = use_context_provider(|| Signal::new(UserProgress::load()));
635
636    // Completion checkmarks persist per browser via localStorage.
637    #[cfg(target_arch = "wasm32")]
638    {
639        use gloo_storage::Storage;
640        use_effect(move || {
641            if let Ok(saved) = gloo_storage::LocalStorage::get::<Vec<String>>("guide-completed") {
642                completed.set(saved);
643            }
644        });
645    }
646    let toggle_completed = move |id: String| {
647        let mut list = completed.read().clone();
648        if let Some(pos) = list.iter().position(|x| *x == id) {
649            list.remove(pos);
650        } else {
651            list.push(id);
652        }
653        #[cfg(target_arch = "wasm32")]
654        {
655            use gloo_storage::Storage;
656            let _ = gloo_storage::LocalStorage::set("guide-completed", &list);
657        }
658        completed.set(list);
659    };
660
661    // Build section info for sidebar — the mastery ring counts exercises
662    // passed (correct at least once) via their namespaced SM-2 entries.
663    let sections_info: Vec<SectionInfo> = {
664        let prog = progress.read();
665        SECTIONS
666            .iter()
667            .map(|s| {
668                let exercise_total = s.exercises.len();
669                let exercise_passed = s
670                    .exercises
671                    .iter()
672                    .filter(|e| {
673                        prog.exercises
674                            .get(&crate::guide_review::exercise_key(e.id))
675                            .is_some_and(|p| p.correct_count > 0)
676                    })
677                    .count();
678                SectionInfo {
679                    id: s.id.to_string(),
680                    number: s.number,
681                    title: s.title.to_string(),
682                    part: s.part.to_string(),
683                    exercise_total,
684                    exercise_passed,
685                }
686            })
687            .collect()
688    };
689
690    // Collect all section IDs for intersection observer
691    #[allow(unused_variables)]
692    let section_ids: Vec<String> = SECTIONS.iter().map(|s| s.id.to_string()).collect();
693
694    // Set up scroll tracking with IntersectionObserver
695    #[cfg(target_arch = "wasm32")]
696    {
697        use wasm_bindgen::prelude::*;
698        use wasm_bindgen::JsCast;
699
700        let section_ids_for_effect = section_ids.clone();
701
702        use_effect(move || {
703            let window = match web_sys::window() {
704                Some(w) => w,
705                None => return,
706            };
707            let document = match window.document() {
708                Some(d) => d,
709                None => return,
710            };
711
712            // Use RefCell to allow mutation from within Fn closure
713            use std::cell::RefCell;
714            use std::rc::Rc;
715
716            let active_section_clone = Rc::new(RefCell::new(active_section.clone()));
717            let active_section_for_closure = active_section_clone.clone();
718
719            let callback = Closure::<dyn Fn(js_sys::Array, web_sys::IntersectionObserver)>::new(
720                move |entries: js_sys::Array, _observer: web_sys::IntersectionObserver| {
721                    // Simple approach: when a section crosses the threshold line,
722                    // it becomes active
723                    for i in 0..entries.length() {
724                        if let Ok(entry) = entries
725                            .get(i)
726                            .dyn_into::<web_sys::IntersectionObserverEntry>()
727                        {
728                            if entry.is_intersecting() {
729                                let target = entry.target();
730                                let id = target.id();
731                                if !id.is_empty() {
732                                    active_section_for_closure.borrow_mut().set(id);
733                                }
734                            }
735                        }
736                    }
737                },
738            );
739
740            // Create IntersectionObserver options
741            let mut options = web_sys::IntersectionObserverInit::new();
742            // Root margin creates a thin "tripwire" near the top of the screen
743            options.root_margin("-100px 0px -90% 0px");
744            let thresholds = js_sys::Array::new();
745            thresholds.push(&JsValue::from(0.0));
746            options.threshold(&thresholds);
747
748            // Create the observer
749            let observer = match web_sys::IntersectionObserver::new_with_options(
750                callback.as_ref().unchecked_ref(),
751                &options,
752            ) {
753                Ok(obs) => obs,
754                Err(_) => return,
755            };
756
757            // Observe all sections
758            for section_id in &section_ids_for_effect {
759                if let Some(element) = document.get_element_by_id(section_id) {
760                    observer.observe(&element);
761                }
762            }
763
764            // Keep callback alive
765            callback.forget();
766        });
767    }
768
769    // Track current part for dividers
770    let mut current_part = String::new();
771
772    let breadcrumbs = vec![
773        BreadcrumbItem {
774            name: "Home",
775            path: "/",
776        },
777        BreadcrumbItem {
778            name: "Syntax Guide",
779            path: "/guide",
780        },
781    ];
782    let schemas = vec![
783        organization_schema(),
784        tech_article_schema("LOGOS Syntax Guide", "Comprehensive guide to LOGOS syntax, features, and First-Order Logic concepts with interactive examples.", "/guide"),
785        breadcrumb_schema(&breadcrumbs),
786    ];
787
788    rsx! {
789        PageHead {
790            title: seo_pages::GUIDE.title,
791            description: seo_pages::GUIDE.description,
792            canonical_path: seo_pages::GUIDE.canonical_path,
793        }
794        style { "{GUIDE_STYLE}" }
795        JsonLdMultiple { schemas }
796
797        div { class: "guide-page",
798            // Navigation
799            MainNav {
800                active: ActivePage::Guide,
801                subtitle: Some("Programmer's Guide"),
802            }
803
804            // Hero
805            header { class: "guide-hero",
806                div { class: "guide-hero-badge",
807                    div { class: "dot" }
808                    span { "Interactive Guide" }
809                }
810                h1 { "LOGOS Syntax Guide" }
811                p {
812                    "Write English. Get Logic. Run Code. A comprehensive guide to programming in LOGOS, from basics to advanced features."
813                }
814
815                // In-guide search
816                div {
817                    style: "max-width: 560px; margin: 18px auto 0; position: relative;",
818                    input {
819                        class: "guide-search-input",
820                        style: "width: 100%; padding: 10px 16px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.15); background: rgba(0,0,0,0.35); color: rgba(229,231,235,0.95); font-size: 14px; outline: none;",
821                        placeholder: "Search the guide — sections, prose, code…",
822                        value: "{search_query}",
823                        oninput: move |evt| search_query.set(evt.value()),
824                    }
825                    {
826                        let q = search_query.read().clone();
827                        let hits = search_sections(&q);
828                        if !hits.is_empty() {
829                            rsx! {
830                                div {
831                                    style: "position: absolute; z-index: 30; left: 0; right: 0; margin-top: 6px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.12); background: #0b1022; box-shadow: 0 12px 40px rgba(0,0,0,0.5); text-align: left; max-height: 300px; overflow-y: auto;",
832                                    for hit in hits.into_iter().take(8) {
833                                        a {
834                                            style: "display: block; padding: 9px 14px; color: rgba(229,231,235,0.9); text-decoration: none; font-size: 13px; border-bottom: 1px solid rgba(255,255,255,0.05);",
835                                            href: "#{hit.id}",
836                                            onclick: move |_| search_query.set(String::new()),
837                                            span { style: "opacity: 0.55; margin-right: 8px;", "{hit.number}." }
838                                            "{hit.title}"
839                                        }
840                                    }
841                                }
842                            }
843                        } else {
844                            rsx! {}
845                        }
846                    }
847                }
848            }
849
850            // Main layout
851            div { class: "guide-layout",
852                // Sidebar
853                GuideSidebar {
854                    sections: sections_info,
855                    active_section: active_section.read().clone(),
856                    on_section_click: move |id: String| {
857                        active_section.set(id);
858                    },
859                    completed: completed.read().clone(),
860                    on_toggle_complete: toggle_completed,
861                }
862
863                // Content
864                main { class: "guide-content",
865                    div {
866                        style: "display: flex; justify-content: flex-end; margin-bottom: 4px;",
867                        button {
868                            class: "guide-expand-all",
869                            style: "padding: 6px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); color: rgba(229,231,235,0.75); font-size: 12px; cursor: pointer;",
870                            onclick: move |_| {
871                                if collapsed_groups.read().is_empty() {
872                                    let all: std::collections::HashSet<String> = SECTIONS
873                                        .iter()
874                                        .filter(|s| s.examples.len() > 3)
875                                        .map(|s| s.id.to_string())
876                                        .collect();
877                                    collapsed_groups.set(all);
878                                } else {
879                                    collapsed_groups.set(std::collections::HashSet::new());
880                                }
881                            },
882                            if collapsed_groups.read().is_empty() { "Collapse example groups" } else { "Expand all examples" }
883                        }
884                    }
885                    for section in SECTIONS.iter() {
886                        {
887                            // Check if we need a part divider
888                            let show_divider = section.part != current_part && section.number > 1;
889                            current_part = section.part.to_string();
890
891                            rsx! {
892                                // Part divider
893                                if show_divider {
894                                    div { class: "guide-part-divider",
895                                        h2 { "{section.part}" }
896                                    }
897                                }
898
899                                // Section
900                                section {
901                                    id: "{section.id}",
902                                    class: "guide-section",
903
904                                    h2 {
905                                        span { class: "section-number", "{section.number}." }
906                                        "{section.title}"
907                                    }
908
909                                    // The headache before the aspirin — the hook
910                                    // opens the section, before any definition.
911                                    if !section.hook.is_empty() {
912                                        div { class: "guide-hook", "{section.hook}" }
913                                    }
914
915                                    // "After this section you can:" — the contract
916                                    // the recap questions collect on.
917                                    if !section.objectives.is_empty() {
918                                        div { class: "guide-objectives",
919                                            div { class: "guide-objectives-title", "After this section you can" }
920                                            ul {
921                                                for objective in section.objectives.iter() {
922                                                    li { "{objective}" }
923                                                }
924                                            }
925                                        }
926                                    }
927
928                                    // The prose, the LOGOS it quotes in the shared editor
929                                    for block in prose_blocks(section.content) {
930                                        {prose_block(block)}
931                                    }
932
933                                    // Imported beliefs, confronted before the
934                                    // examples that settle them.
935                                    for m in section.misconceptions.iter() {
936                                        MisconceptionBox {
937                                            id: m.id.to_string(),
938                                            expectation: m.expectation.to_string(),
939                                            reality: m.reality.to_string(),
940                                            evidence: m.evidence.to_string(),
941                                        }
942                                    }
943
944                                    // Render code examples - collapsible if many
945                                    if !section.examples.is_empty() {
946                                        {
947                                            let example_count = section.examples.len();
948                                            let should_collapse = example_count > 3;
949
950                                            if should_collapse {
951                                                rsx! {
952                                                    CollapsibleExamples {
953                                                        section_id: section.id.to_string(),
954                                                        examples: section.examples,
955                                                        is_open: !collapsed_groups.read().contains(section.id),
956                                                        on_toggle: move |id: String| {
957                                                            let mut set = collapsed_groups.read().clone();
958                                                            if !set.remove(&id) {
959                                                                set.insert(id);
960                                                            }
961                                                            collapsed_groups.set(set);
962                                                        },
963                                                    }
964                                                }
965                                            } else {
966                                                rsx! {
967                                                    div { class: "guide-examples",
968                                                        for example in section.examples.iter() {
969                                                            GuideCodeBlock {
970                                                                id: example.id.to_string(),
971                                                                label: example.label.to_string(),
972                                                                mode: example.mode,
973                                                                runnability: example.runnability,
974                                                                expected_output: example.expected_output.map(|s| s.to_string()),
975                                                                predict: example.predict_first,
976                                                                initial_code: example.code.to_string(),
977                                                            }
978                                                        }
979                                                    }
980                                                }
981                                            }
982                                        }
983                                    }
984
985                                    // "Now you try" exercises, graded in place
986                                    for ex in section.exercises.iter() {
987                                        ExerciseBlock {
988                                            id: ex.id.to_string(),
989                                            prompt: ex.prompt.to_string(),
990                                            mode: ex.mode,
991                                            expected: ex.expected.to_string(),
992                                            starter: ex.starter.to_string(),
993                                            kind: ex.kind,
994                                            concepts: ex.concepts.iter().map(|c| c.to_string()).collect::<Vec<_>>(),
995                                            hints: ex.hints.iter().map(|h| h.to_string()).collect::<Vec<_>>(),
996                                        }
997                                    }
998
999                                    // "Check yourself" — free recall with reveal;
1000                                    // self-marks feed the SM-2 review deck.
1001                                    if !section.recap.is_empty() {
1002                                        RecallBlock {
1003                                            cards: section.recap.iter().map(|r| {
1004                                                (r.id.to_string(), r.question.to_string(), r.answer.to_string())
1005                                            }).collect::<Vec<_>>(),
1006                                        }
1007                                    }
1008                                }
1009                            }
1010                        }
1011                        div { class: "guide-community-cta",
1012                            p { "Have questions or feedback? Join our Discord community." }
1013                            a {
1014                                href: "https://discord.gg/pwnjnXvUHH",
1015                                target: "_blank",
1016                                rel: "noopener noreferrer",
1017                                "Join Discord →"
1018                            }
1019                        }
1020                    }
1021                }
1022            }
1023
1024            Footer {}
1025        }
1026    }
1027}
1028
1029/// Collapsible examples component for sections with many examples. Controlled
1030/// by the page: open by default, closable per group or via collapse-all.
1031#[component]
1032fn CollapsibleExamples(
1033    section_id: String,
1034    examples: &'static [content::CodeExample],
1035    is_open: bool,
1036    on_toggle: EventHandler<String>,
1037) -> Element {
1038    let example_count = examples.len();
1039
1040    rsx! {
1041        div {
1042            class: if is_open { "guide-examples-collapsible expanded" } else { "guide-examples-collapsible" },
1043
1044            div {
1045                class: "guide-examples-header",
1046                onclick: {
1047                    let id = section_id.clone();
1048                    move |_| on_toggle.call(id.clone())
1049                },
1050
1051                div { class: "guide-examples-header-left",
1052                    span { class: "guide-examples-title", "Code Examples" }
1053                    span { class: "guide-examples-count", "{example_count} examples" }
1054                }
1055                span { class: "guide-examples-chevron", "▶" }
1056            }
1057
1058            div { class: "guide-examples-content",
1059                div { class: "guide-examples-inner",
1060                    for example in examples.iter() {
1061                        GuideCodeBlock {
1062                            id: example.id.to_string(),
1063                            label: example.label.to_string(),
1064                            mode: example.mode,
1065                            runnability: example.runnability,
1066                            expected_output: example.expected_output.map(|s| s.to_string()),
1067                            predict: example.predict_first,
1068                            initial_code: example.code.to_string(),
1069                        }
1070                    }
1071                }
1072            }
1073        }
1074    }
1075}
1076
1077/// A section's prose as one piece of static markup, every fence a plain copyable block — the
1078/// form the markdown locks read. The page itself renders [`prose_blocks`], quoting LOGOS fences
1079/// in the shared editor.
1080fn render_markdown(content: &str) -> String {
1081    prose_blocks(content)
1082        .into_iter()
1083        .map(|block| match block {
1084            ProseBlock::Html(html) => html,
1085            ProseBlock::Code { code, .. } => code_block_html(&code),
1086        })
1087        .collect()
1088}
1089
1090/// A block of guide prose as markup, its text marked up by the site's one inline renderer. A quote that opens with
1091/// `**Mental model:**` or `**Look back:**` is styled as that callout; the pedagogy battery rides
1092/// on those conventions.
1093fn prose_html(block: &Block) -> String {
1094    match block {
1095        Block::Heading { level, text } => {
1096            format!("<h{level}>{}</h{level}>", inline_markup_html(text))
1097        }
1098        Block::Paragraph(text) => format!("<p>{}</p>", inline_markup_html(text)),
1099        Block::List { ordered, items } => {
1100            let tag = if *ordered { "ol" } else { "ul" };
1101            let items: String = items
1102                .iter()
1103                .map(|item| format!("<li>{}</li>", inline_markup_html(item)))
1104                .collect();
1105            format!("<{tag}>{items}</{tag}>")
1106        }
1107        Block::Quote(paragraphs) => {
1108            let opener = paragraphs.first().map_or("", String::as_str);
1109            let class = if opener.contains("**Mental model:**") {
1110                " class=\"guide-callout guide-mental-model\""
1111            } else if opener.contains("**Look back:**") {
1112                " class=\"guide-callout guide-look-back\""
1113            } else {
1114                ""
1115            };
1116            let body: Vec<String> = paragraphs.iter().map(|p| inline_markup_html(p)).collect();
1117            format!(
1118                "<blockquote{class}>{}</blockquote>",
1119                body.join("<br/><br/>")
1120            )
1121        }
1122        Block::Table { header, rows } => {
1123            let row = |cells: &[String], tag: &str| -> String {
1124                let cells: String = cells
1125                    .iter()
1126                    .map(|cell| format!("<{tag}>{}</{tag}>", inline_markup_html(cell)))
1127                    .collect();
1128                format!("<tr>{cells}</tr>")
1129            };
1130            let head = header.as_ref().map_or(String::new(), |h| {
1131                format!("<thead>{}</thead>", row(h, "th"))
1132            });
1133            let body: String = rows.iter().map(|r| row(r, "td")).collect();
1134            format!("<div class=\"table-wrapper\"><table>{head}<tbody>{body}</tbody></table></div>")
1135        }
1136        Block::Fence { code, .. } => code_block_html(code),
1137        Block::Rule => "<hr/>".to_string(),
1138    }
1139}
1140
1141/// The markdown renderer, exported for the render locks — the blockquote
1142/// callout conventions (`> **Mental model:**`, `> **Look back:**`) are load-
1143/// bearing for the pedagogy battery and must never silently stop parsing.
1144pub fn render_markdown_for_test(content: &str) -> String {
1145    render_markdown(content)
1146}
1147
1148/// One piece of a section's prose, in page order: rendered markup, or a fenced code block.
1149#[derive(Clone, Debug, PartialEq)]
1150pub enum ProseBlock {
1151    Html(String),
1152    /// The language its opening fence names (empty when it names none) and its lines as written.
1153    Code {
1154        tag: String,
1155        code: String,
1156    },
1157}
1158
1159/// How the page shows a fenced block, by the language its opening fence names.
1160#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1161pub enum Fence {
1162    /// ```` ```logos ````: LOGOS code, in the shared editor with the language server.
1163    Logos,
1164    /// ```` ```logic ````: sentences and theorems, in the same editor, read as Logic.
1165    Logic,
1166    /// Shell commands and configuration: plain, copyable text.
1167    Plain,
1168}
1169
1170impl Fence {
1171    /// The editor language the block is read in; plain text has none.
1172    pub fn language(self) -> Option<Language> {
1173        match self {
1174            Fence::Logos => Some(Language::Logos),
1175            Fence::Logic => Some(Language::Logic),
1176            Fence::Plain => None,
1177        }
1178    }
1179}
1180
1181/// The fence a tag names, or `None` for a tag the guide does not render.
1182pub fn fence(tag: &str) -> Option<Fence> {
1183    match tag {
1184        "logos" => Some(Fence::Logos),
1185        "logic" => Some(Fence::Logic),
1186        "bash" | "powershell" | "toml" => Some(Fence::Plain),
1187        _ => None,
1188    }
1189}
1190
1191/// A section's prose split at its fences, in page order. A fence's lines are kept exactly as
1192/// written, blank lines and indentation included; whatever prose was open before it (a list, a
1193/// table, a quote) ends where it begins.
1194pub fn prose_blocks(content: &str) -> Vec<ProseBlock> {
1195    let mut out = Vec::new();
1196    let mut html = String::new();
1197    for block in blocks(content) {
1198        match block {
1199            Block::Fence { tag, code } => {
1200                if !html.is_empty() {
1201                    out.push(ProseBlock::Html(std::mem::take(&mut html)));
1202                }
1203                out.push(ProseBlock::Code { tag, code });
1204            }
1205            block => html.push_str(&prose_html(&block)),
1206        }
1207    }
1208    if !html.is_empty() {
1209        out.push(ProseBlock::Html(html));
1210    }
1211    out
1212}
1213
1214/// One piece of a section's prose on the page: markup as it renders, LOGOS and Logic quoted in
1215/// the shared editor, and shell and config text as a plain copyable block.
1216fn prose_block(block: ProseBlock) -> Element {
1217    match block {
1218        ProseBlock::Html(html) => rsx! {
1219            div { dangerous_inner_html: html }
1220        },
1221        ProseBlock::Code { tag, code } => match fence(&tag).and_then(Fence::language) {
1222            Some(language) => rsx! {
1223                GuideSnippet { code, language }
1224            },
1225            None => rsx! {
1226                div { dangerous_inner_html: code_block_html(&code) }
1227            },
1228        },
1229    }
1230}
1231
1232/// A fenced code block as copy-friendly HTML: a Copy button (clipboard API, inline
1233/// handler — the block arrives via `dangerous_inner_html`, so it can't carry a
1234/// Dioxus listener) over an escaped `<pre><code>`.
1235fn code_block_html(code: &str) -> String {
1236    format!(
1237        "<div class=\"guide-fence\">\
1238         <button class=\"guide-code-copy\" \
1239         onclick=\"navigator.clipboard.writeText(this.nextElementSibling.textContent);\
1240         this.textContent='Copied!';setTimeout(()=>{{this.textContent='Copy'}},1200)\">Copy</button>\
1241         <pre><code>{}</code></pre></div>",
1242        html_escape(code.trim_end_matches('\n'))
1243    )
1244}
1245
1246fn html_escape(s: &str) -> String {
1247    s.replace('&', "&amp;")
1248        .replace('<', "&lt;")
1249        .replace('>', "&gt;")
1250}
1251
1252#[cfg(test)]
1253#[path = "../../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/pages/guide/render_markdown_tests.rs"]
1254mod render_markdown_tests;
1255
1256#[cfg(test)]
1257#[path = "../../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/pages/guide/prose_block_tests.rs"]
1258mod prose_block_tests;