diff --git a/src/bin/server.rs b/src/bin/server.rs index 6448412..4b24909 100644 --- a/src/bin/server.rs +++ b/src/bin/server.rs @@ -97,6 +97,24 @@ struct PlaygroundTemplate { starter: String, } +/// Template for the read-only "A Quick Tour of Rust" preamble page. +/// Renders one editable, runnable code box (the Mario tour) with +/// concept-class hover explanations. +#[derive(Template)] +#[template(path = "tour.html")] +struct TourTemplate { + /// The annotated tour source shown in the editor. + starter: String, + /// Always `None`: the tour is anonymous preamble with no participant + /// context. Present so it can share `partials/next_chapter_cta.html`. + ulid: Option, + /// The first real chapter, rendered as the closing "Next chapter" + /// CTA via the shared partial. `None` if the catalog is empty. + next_dot: Option, + /// Always `false`: the tour's CTA is never locked behind completion. + next_locked: bool, +} + /// Template for the cheatsheet page. Just renders pre-built HTML /// produced from `static/cheatsheet.md` at startup. #[derive(Template)] @@ -118,6 +136,8 @@ struct ExerciseTemplate { /// One entry per exercise in the catalog, ordered by `number`. /// Used to render the bottom "chapter list" navigation. dots: Vec, + /// Rows in the first TOC column; see [`chapter_rows`]. + chapter_rows: usize, /// Ordered render plan: prose blocks and code sections in display /// order. Each `Code` carries the per-step status and the database /// key (`/` or just `` for legacy). @@ -126,6 +146,11 @@ struct ExerciseTemplate { /// or `None` when this is the last chapter. Used for the /// "Next chapter" call-to-action at the bottom of the page. next_dot: Option, + /// When `true`, the next-chapter CTA renders hidden (`.is-locked`) + /// until the participant completes the current chapter. Always + /// `false` on the public route. Consumed by + /// `partials/next_chapter_cta.html`. + next_locked: bool, /// Number of completable chapters the participant has finished. /// Quizzes and notes-only chapters don't count toward either total. progress_done: usize, @@ -155,6 +180,32 @@ struct ProgressDot { /// `true` for optional bonus chapters: hidden from the TOC/picker and /// excluded from progress and the next-chapter flow. is_bonus: bool, + /// Optional explicit link target. When `Some`, the TOC partial and + /// chapter picker link straight here instead of deriving an + /// `/exercise/{slug}` URL. Used for non-exercise entries like the + /// read-only "Quick Tour" preamble (`/tour`). + href: Option, +} + +/// Synthetic chapter-list entry for the read-only "A Quick Tour of +/// Rust" preamble. It isn't a real exercise (no code steps, no tests, +/// no progress), so it carries an explicit `/tour` href and +/// `has_exercises = false` to stay out of the progress totals while +/// still showing as the first row of the table of contents. +fn tour_dot() -> ProgressDot { + ProgressDot { + slug: "tour".to_string(), + number: 0, + title: "A Quick Tour of Rust".to_string(), + attempted: false, + completed: false, + perfected: false, + current: false, + is_quiz: false, + has_exercises: false, + is_bonus: false, + href: Some("/tour".to_string()), + } } /// Per-exercise progress used by the chapter list and current-status badge. @@ -191,6 +242,8 @@ struct DashboardTemplate { /// two stay visually identical. `current` is always `false` /// here because the homepage isn't any one chapter. dots: Vec, + /// Rows in the first TOC column; see [`chapter_rows`]. + chapter_rows: usize, /// Slug of the first chapter the participant hasn't completed yet, /// or the first chapter overall if they're brand new / fully done. /// Used by the "Start" call-to-action. @@ -815,6 +868,8 @@ async fn main() -> Result<()> { .route("/exercise/{slug}", get(public_exercise_page)) .route("/exercise/{ulid}/{slug}", get(participant_exercise_page)) .route("/playground", get(playground_page)) + .route("/tour", get(tour_page)) + .route("/tour/{ulid}", get(tour_page_with_ulid)) .route("/settings", get(settings_page)) .route("/settings/{ulid}", get(participant_settings_page)) .route("/cheatsheet", get(cheatsheet_page)) @@ -890,9 +945,8 @@ async fn health_check(State(state): State) -> impl IntoResponse { /// so both pages feed the same `partials/chapter_list.html` partial. /// `current` is always `false` on the homepage. fn dots_from_exercises(exercises: &[ExerciseProgress]) -> Vec { - exercises - .iter() - .map(|e| ProgressDot { + std::iter::once(tour_dot()) + .chain(exercises.iter().map(|e| ProgressDot { slug: e.name.clone(), number: e.number, title: e.title.clone(), @@ -903,10 +957,21 @@ fn dots_from_exercises(exercises: &[ExerciseProgress]) -> Vec { is_quiz: e.is_quiz, has_exercises: e.has_exercises, is_bonus: e.is_bonus, - }) + href: None, + })) .collect() } +/// Number of rows in the first column of the two-column table of +/// contents (`partials/chapter_list.html`). The list uses +/// `grid-auto-flow: column` with `--chapter-rows` rows, filling the +/// first column before the second, so this is `ceil(visible / 2)`. +/// Counts only rendered (non-bonus) rows so the two columns stay +/// balanced as chapters are added or removed. +fn chapter_rows(dots: &[ProgressDot]) -> usize { + dots.iter().filter(|d| !d.is_bonus).count().div_ceil(2) +} + /// Anonymous dashboard at `/`. /// /// Renders the same `dashboard.html` template the participant view @@ -952,10 +1017,12 @@ async fn anonymous_dashboard( .filter(|e| !e.is_quiz && e.has_exercises && !e.is_bonus) .count(); + let dots = dots_from_exercises(&exercises); let template = DashboardTemplate { participant_name: None, ulid: None, - dots: dots_from_exercises(&exercises), + chapter_rows: chapter_rows(&dots), + dots, next_slug, next_label, next_chapter_number, @@ -1013,6 +1080,64 @@ fn render_signup(team_slug: Option) -> axum::response::Response { ) } +/// Read-only "A Quick Tour of Rust" preamble page at `/tour`. +/// +/// Ships the annotated Mario tour source (embedded at compile time) +/// into one editable, runnable code box. Like the playground, edits +/// live in `localStorage` and "Run" proxies to play.rust-lang.org; the +/// page adds concept-class hover explanations on top. +async fn tour_page(State(state): State) -> impl IntoResponse { + render_tour(&state, None) +} + +/// Same tour page, but reached with a participant ULID (e.g. straight +/// after signing up on the dashboard warm-up). The ULID is threaded +/// into the closing "Next chapter" CTA so the learner keeps their +/// progress context when they move on to chapter 1. +async fn tour_page_with_ulid( + AxumPath(ulid): AxumPath, + State(state): State, +) -> impl IntoResponse { + render_tour(&state, Some(ulid)) +} + +fn render_tour(state: &AppState, ulid: Option) -> axum::response::Html { + const STARTER: &str = include_str!("../../static/tour_starter.rs"); + // Derive the first real chapter the same way the dashboard does, so + // the closing CTA keeps pointing at the right place even if chapters + // are renamed or reordered. + let first = state + .exercises + .iter() + .find(|e| !e.is_quiz() && !e.is_bonus() && !e.code_steps().is_empty()); + let next_dot = first.map(|e| ProgressDot { + slug: e.slug.clone(), + number: e.number, + title: e.title.clone(), + attempted: false, + completed: false, + perfected: false, + current: false, + is_quiz: e.is_quiz(), + has_exercises: !e.code_steps().is_empty(), + is_bonus: e.is_bonus(), + href: None, + }); + let template = TourTemplate { + starter: STARTER.to_string(), + ulid, + next_dot, + next_locked: false, + }; + match template.render() { + Ok(html) => Html(html), + Err(e) => { + error!("tour template render failed: {e}"); + Html("Error rendering template".to_string()) + } + } +} + /// Standalone Rust scratchpad. Code is persisted client-side in /// `localStorage`; this handler only ships the starter snippet. async fn playground_page() -> impl IntoResponse { @@ -1182,10 +1307,12 @@ async fn participant_dashboard( .count(); let team_token = participant.parsed_team_token(); + let dots = dots_from_exercises(&exercises); let template = DashboardTemplate { participant_name: Some(participant.name), ulid: Some(ulid.clone()), - dots: dots_from_exercises(&exercises), + chapter_rows: chapter_rows(&dots), + dots, next_slug, next_label, next_chapter_number, @@ -1314,11 +1441,8 @@ async fn render_exercise_page( .cloned() .unwrap_or_default(); - let dots: Vec = state - .exercises - .iter() - .enumerate() - .map(|(i, e)| { + let dots: Vec = std::iter::once(tour_dot()) + .chain(state.exercises.iter().enumerate().map(|(i, e)| { let s = chapter_progress .get(&e.file_stem) .cloned() @@ -1334,8 +1458,9 @@ async fn render_exercise_page( is_quiz: e.is_quiz(), has_exercises: !e.code_steps().is_empty(), is_bonus: e.is_bonus(), + href: None, } - }) + })) .collect(); // Build the ordered render plan from the chapter's steps. @@ -1430,8 +1555,14 @@ async fn render_exercise_page( // Next chapter for the bottom CTA: the first non-bonus dot after the // current one. We don't skip quizzes or appendices (the picker shows // them), but bonus chapters are hidden from the picker, so the CTA - // skips them too. - let next_dot = dots.iter().skip(idx + 1).find(|d| !d.is_bonus).cloned(); + // skips them too. Locate the current dot by its flag rather than by + // `idx`, since `dots` is prefixed with the synthetic tour entry. + let next_dot = dots + .iter() + .skip_while(|d| !d.current) + .skip(1) + .find(|d| !d.is_bonus) + .cloned(); // Progress: how many completable chapters has the participant // finished? Quizzes and notes-only chapters never "complete", so @@ -1445,13 +1576,16 @@ async fn render_exercise_page( .filter(|d| !d.is_quiz && d.has_exercises && !d.is_bonus && d.completed) .count(); + let next_locked = ulid.is_some() && !current_status.completed; let template = ExerciseTemplate { exercise, ulid, current_status, + chapter_rows: chapter_rows(&dots), dots, items, next_dot, + next_locked, progress_done, progress_total, }; diff --git a/static/js/inline-editor.js b/static/js/inline-editor.js index 2ff5ced..083c137 100644 --- a/static/js/inline-editor.js +++ b/static/js/inline-editor.js @@ -527,6 +527,15 @@ export async function mountInlineEditor(section, opts = {}) { ...(urlPlugin ? [urlPlugin, urlTheme] : []), themeCompartment.of(proseEditorTheme), persistExt, + // Page-specific extras (e.g. the tour's hover-explanation + // tooltips). Built here so callers reuse the exact CM module + // instances resolved through the shared importmap. The value may + // be a single extension or an array; CodeMirror flattens nested + // arrays, so we include it as one element rather than spreading + // (spreading a non-iterable single extension would throw). + typeof features.buildExtraExtensions === "function" + ? features.buildExtraExtensions(cmModules) || [] + : [], ]; editor = new EditorView({ diff --git a/static/tour_starter.rs b/static/tour_starter.rs new file mode 100644 index 0000000..15090da --- /dev/null +++ b/static/tour_starter.rs @@ -0,0 +1,123 @@ +#![allow(dead_code, unused_variables)] + +// A quick tour of Rust, on one page. +// Hover any keyword or type for a one-line explanation. +// You don't need to understand every detail yet. Just soak it in, +// then press Run to watch World 1-1 play out. + +// `let` binds a name to a value. Bindings are immutable unless you add `mut`. +fn basics() { + let player = "Mario"; // type inferred: a string slice (&str) + let mut coins = 0; // `mut` lets this one change + coins += 1; + + let lives: u8 = 3; // an explicit type: unsigned 8-bit integer + let stats = (1, 1, 400); // a tuple groups values: (world, level, time) + + // `if` is an expression: it evaluates to a value you can bind. + let status = if coins >= 100 { + "1-Up!" + } else { + "keep collecting" + }; + + // Two kinds of loop. + for enemy in ["Goomba", "Koopa"] { + // runs once per item + } + let mut timer = 10; + while timer > 0 { + timer -= 1; + } +} + +// An `enum` is a type with a fixed set of variants. Variants can carry data. +enum PowerUp { + Small, + Mushroom, + Star(u32), // this variant holds the seconds of invincibility left +} + +// A `struct` groups related fields under one named type. +struct Player { + name: String, + power: PowerUp, +} + +// A `trait` describes behavior that many types can share. +trait Jump { + fn jump(&self); +} + +// Implement the `Jump` behavior for our `Player`. +impl Jump for Player { + fn jump(&self) { + // `match` is exhaustive: every variant must be handled. + match self.power { + PowerUp::Star(secs) => println!("{} leaps, invincible for {secs}s!", self.name), + PowerUp::Mushroom => println!("{} takes a high jump!", self.name), + PowerUp::Small => println!("{} hops.", self.name), + } + } +} + +// Ownership: every value has one owner. Assigning it hands ownership over. +fn ownership() { + let shell = String::from("green shell"); + let kicked = shell; // `shell` is moved into `kicked` + // println!("{shell}"); // would not compile: `shell` no longer owns the value + println!("Mario kicks the {kicked}."); +} + +// Borrowing: lend a value with `&` instead of giving it away. +fn add_points(score: &mut i32) { + *score += 100; // `*` writes through the mutable reference +} +fn report(score: &i32) { + println!("Score: {score}"); // a shared `&` reference is read-only +} + +// `Option` models a value that might be missing. Rust has no null. +fn hit_block(empty: bool) -> Option<&'static str> { + if empty { None } else { Some("coin") } +} + +// `Result` models an operation that can fail. Rust has no exceptions. +fn enter_pipe(id: u8) -> Result<&'static str, &'static str> { + match id { + 1 => Ok("warp zone"), + _ => Err("piranha plant!"), + } +} + +// Every program starts at `main`. +fn main() { + let mut mario = Player { + name: String::from("Mario"), + power: PowerUp::Mushroom, + }; + + println!("World 1-1"); + mario.jump(); + + // `if let` runs the block only when the Option is `Some`. + if let Some(item) = hit_block(false) { + println!("Mario found a {item}!"); + } + + let mut score = 0; + add_points(&mut score); // lend `score` mutably + report(&score); // lend `score` just to read it + + // Grab a star and become invincible. + mario.power = PowerUp::Star(10); + mario.jump(); + + // Handle both outcomes of a fallible call with `match`. + match enter_pipe(1) { + Ok(place) => println!("Mario enters the {place}."), + Err(danger) => println!("Ouch: {danger}"), + } + + println!("World 1-1 clear! Flagpole reached. 🏁"); +} diff --git a/templates/base.html b/templates/base.html index d9d7805..8c066d3 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1550,6 +1550,78 @@ font-style: italic; } + /* ---------- Next-chapter CTA ---------- */ + /* Rendered by `templates/partials/next_chapter_cta.html` at + the bottom of exercise pages and the quick tour. Exercise + pages add `.is-locked` to hide it until the chapter is + completed (revealed by the run-success hook in + `exercise.html`); the tour leaves it always visible. */ + .next-chapter-cta { + margin: 3rem auto 0; + max-width: 32rem; + text-align: center; + } + .next-chapter-cta.is-locked { + display: none; + } + .next-chapter-btn { + display: inline-grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + column-gap: 1rem; + row-gap: 0.15rem; + align-items: center; + text-align: left; + text-decoration: none; + padding: 1rem 1.5rem; + min-height: auto; + height: auto; + font-size: 1rem; + } + .next-chapter-btn:hover, + .next-chapter-btn:focus-visible { + text-decoration: none; + } + .next-chapter-eyebrow { + grid-column: 1; + grid-row: 1; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.18em; + font-weight: 600; + opacity: 0.8; + } + .next-chapter-title { + grid-column: 1; + grid-row: 2; + display: inline-flex; + align-items: baseline; + gap: 0.55rem; + font-size: 1.1rem; + font-weight: 600; + line-height: 1.2; + } + .next-chapter-num { + font-family: + "JetBrains Mono", "SF Mono", Monaco, Menlo, Consolas, + monospace; + font-size: 0.95rem; + font-weight: 600; + opacity: 0.85; + font-variant-numeric: tabular-nums; + } + .next-chapter-arrow { + grid-column: 2; + grid-row: 1 / span 2; + font-size: 1.4rem; + line-height: 1; + transition: transform 0.15s ease; + } + .next-chapter-btn:hover .next-chapter-arrow, + .next-chapter-btn:focus-visible .next-chapter-arrow { + transform: translateX(0.2rem); + } + /* ---------- Quiz block (inline on quiz chapters) ---------- */ /* Rendered by `Quiz::render_html` in src/exercises.rs and wired up by `static/js/quiz.js`. Lives inside the chapter diff --git a/templates/dashboard.html b/templates/dashboard.html index 927ee62..aaca904 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -112,6 +112,11 @@

A Beginner's Guide To Rust

tell you when you're done. Most take five to ten minutes. Skip ahead if a chapter feels familiar, come back when something clicks.

+ {% match team_token %} {% when Some with (token) %} {% match ulid %} {% when Some with (u) %}

A Beginner's Guide To Rust

an env-file parser, and a CSV parser. Skip ahead if a chapter feels familiar; come back when something clicks.

+ {# @@ -265,10 +275,10 @@

Output

{# Inline signup card. Hidden until the warm-up's first successful run. Once visible, the form posts the chosen name to - `/api/register` (JSON), gets a fresh ULID back, and redirects to - chapter 1 (`00_integers`) under that ULID. The fallback `` - right below the form lets visitors who'd rather skip the save - file jump straight in as anonymous. + `/api/register` (JSON), gets a fresh ULID back, and sends the + learner into the quick tour (`/tour/{ulid}`) under that ULID. + The fallback `` right below the form lets visitors who'd + rather skip the save file head to the tour as anonymous. #} @@ -354,7 +362,6 @@

Results

there's nothing to wire up. #} - {% match next_dot %} {% when Some with (n) %} {% if ulid.is_none() && - exercise.wants_signup_on_pass() %} {# Anonymous welcome chapter: the inline - signup card stands in for the next-chapter button. It starts hidden and is - revealed by the `signup_on_pass` pass hook in applyChapterDirectives() the - moment the user gets the first exercise to compile. The hidden `next` field - round-trips through `/register` so the redirect lands on the next chapter - (now with the fresh ULID), not the dashboard. #} + {% if next_dot.is_some() && ulid.is_none() && + exercise.wants_signup_on_pass() %} {% match next_dot %} {% when Some with + (n) %} {# Anonymous welcome chapter: the inline signup card stands in for + the next-chapter button. It starts hidden and is revealed by the + `signup_on_pass` pass hook in applyChapterDirectives() the moment the user + gets the first exercise to compile. The hidden `next` field round-trips + through `/register` so the redirect lands on the next chapter (now with the + fresh ULID), not the dashboard. #} - {% else %} -
- {% match ulid %} {% when Some with (u) %} - - Next chapter - {{ n.number }}{{ n.title - }} - - - {% when None %} - - Next chapter - {{ n.number }}{{ n.title - }} - - - {% endmatch %} -
- {% endif %} {% when None %} {% endmatch %} {% if exercise.shows_toc() %} {% - include "partials/chapter_list.html" %} {% endif %} + {% when None %} {% endmatch %} {% else %} {% include + "partials/next_chapter_cta.html" %} {% endif %} {% if exercise.shows_toc() + %} {% include "partials/chapter_list.html" %} {% endif %} + + +{% endblock %}