From c94b0623c35c9baa9c4cda082d163d94a9a40c80 Mon Sep 17 00:00:00 2001
From: Matthias
Date: Mon, 22 Jun 2026 00:24:44 +0200
Subject: [PATCH] Add "A Quick Tour of Rust" preamble page
Add a read-only preamble that shows most of Rust on a single page,
told as a tiny Super Mario program, before the slow per-concept
chapters begin.
- New /tour route + templates/tour.html: one editable, runnable code
box (reuses the playground's inline editor in run-only mode) plus
concept-class hover explanations driven by a small dictionary and
CodeMirror's hoverTooltip. Tooltips render into document.body so they
aren't clipped by the editor's overflow:hidden.
- Source lives in static/tour_starter.rs (embedded via include_str!),
kept as a real .rs file so it stays editable and syntax-highlighted.
- The tour is the first table-of-contents entry (number 0) and is
surfaced from the dashboard intro and the post-warm-up signup flow,
which now funnels into /tour (ULID-aware via /tour/{ulid} so progress
context carries into chapter 1).
- Extract the "Next chapter" CTA into partials/next_chapter_cta.html and
move its styles to base.html so the tour and exercise pages share one
component; the tour ends on that same button into chapter 1.
- Fix the TOC column split to balance on the visible (non-bonus) row
count via a chapter_rows() helper, so the two columns stay even as
chapters are added or removed.
- Add a features.buildExtraExtensions hook to the inline editor so pages
can layer extra CodeMirror extensions while reusing the shared module
instances.
---
src/bin/server.rs | 162 ++++++++-
static/js/inline-editor.js | 9 +
static/tour_starter.rs | 123 +++++++
templates/base.html | 72 ++++
templates/dashboard.html | 35 +-
templates/exercise.html | 125 ++-----
templates/partials/chapter_list.html | 16 +-
templates/partials/next_chapter_cta.html | 39 +++
templates/tour.html | 407 +++++++++++++++++++++++
9 files changed, 858 insertions(+), 130 deletions(-)
create mode 100644 static/tour_starter.rs
create mode 100644 templates/partials/next_chapter_cta.html
create mode 100644 templates/tour.html
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.
+
+ Want the big picture again?
+ A Quick Tour of Rust → shows the whole language
+ on a single page.
+
{% match team_token %} {% when Some with (token) %} {% match ulid %} {%
when Some with (u) %}
@@ -142,6 +147,11 @@
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.
+
+ New to Rust? Start with
+ A Quick Tour of Rust →, the whole language on a
+ single page before the chapters slow it all down.
+
{#
@@ -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 @@
const signupName = document.getElementById("warmup-signup-name");
const signupSubmit = document.getElementById("warmup-signup-submit");
const signupError = document.getElementById("warmup-signup-error");
- const NEXT_SLUG = "{{ next_slug }}";
function revealSignup() {
if (!signupCard.hasAttribute("hidden")) return;
@@ -418,10 +425,10 @@
});
// ---- Inline signup ---------------------------------------------
- // POSTs the chosen name to /api/register (JSON), then redirects
- // to chapter 1 under the freshly minted ULID. The endpoint
- // validates the name server-side via `Name::try_from`; we surface
- // any 4xx as inline error text rather than alerting.
+ // POSTs the chosen name to /api/register (JSON), then sends the
+ // learner into the quick tour under the freshly minted ULID. The
+ // endpoint validates the name server-side via `Name::try_from`;
+ // we surface any 4xx as inline error text rather than alerting.
signupForm.addEventListener("submit", async (e) => {
e.preventDefault();
const name = (signupName.value || "").trim();
@@ -459,7 +466,7 @@
signupName.disabled = false;
return;
}
- window.location.href = `/exercise/${data.ulid}/${NEXT_SLUG}`;
+ window.location.href = `/tour/${data.ulid}`;
} catch (err) {
console.error(err);
signupError.textContent =
diff --git a/templates/exercise.html b/templates/exercise.html
index d26924e..8ed39fd 100644
--- a/templates/exercise.html
+++ b/templates/exercise.html
@@ -70,6 +70,19 @@
%}
{%
match
+ d.href
+ %}{%
+ when
+ Some
+ with
+ (h)
+ %}
+ href="{{ h }}"
+ {%
+ when
+ None
+ %}{%
+ match
ulid
%}{%
when
@@ -85,6 +98,8 @@
href="/exercise/{{ d.slug }}"
{%
endmatch
+ %}{%
+ endmatch
%}
class="chapter-picker-item{% if d.current %} is-current{% endif %}"
>
@@ -544,13 +559,14 @@ 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. #}
Continue without saving.
- {% else %}
-
- {% 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 %}