From c6726f4aaae4431023fe0a6f09055a25b87b3a25 Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 13:21:17 -0600 Subject: [PATCH 01/27] test(red): export-quarto carries gotchas and packages to the Quarto div The Quarto filter already parses ::: {.gotchas} divs and a packages="a,b" div attribute, but export-quarto dropped both, so exported lessons lost their pitfalls panel and failed to preload dependencies in webR/Pyodide. --- crates/cli/tests/export_quarto.rs | 46 +++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/crates/cli/tests/export_quarto.rs b/crates/cli/tests/export_quarto.rs index 595702c..0146dc9 100644 --- a/crates/cli/tests/export_quarto.rs +++ b/crates/cli/tests/export_quarto.rs @@ -32,7 +32,7 @@ const R_LESSON_MINIMAL: &str = concat!( "/../core/tests/fixtures/lessons/add_two_numbers.yaml" ); -/// The R fixture with gotchas — verifies gotchas text is excluded from output. +/// The R fixture with gotchas — verifies gotchas render as a `.gotchas` div. const R_LESSON_WITH_GOTCHAS: &str = concat!( env!("CARGO_MANIFEST_DIR"), "/../core/tests/fixtures/lessons/gotcha_lesson.yaml" @@ -160,15 +160,17 @@ fn clause_8_llm_evaluation_prompt_absent() { } #[test] -fn clause_9_gotchas_absent() { +fn clause_9_gotchas_render_in_gotchas_div() { + // The Quarto filter parses a nested `::: {.gotchas}` div into the widget's + // gotchas panel, so the export must carry the field rather than drop it. let stdout = export_stdout(R_LESSON_WITH_GOTCHAS); assert!( - !stdout.contains("Vectorized operations apply element-wise"), - "gotchas text must be ABSENT from output, got:\n{stdout}" + stdout.contains("::: {.gotchas}\n- R uses '<-' for assignment, not '='."), + "gotchas should render as a ::: {{.gotchas}} div, got:\n{stdout}" ); assert!( - !stdout.contains("gotchas"), - "the word 'gotchas' must not appear in output, got:\n{stdout}" + stdout.contains("Vectorized operations apply element-wise"), + "every gotcha bullet should be present, got:\n{stdout}" ); } @@ -215,13 +217,35 @@ fn clause_11_no_empty_blocks_for_absent_fields() { } #[test] -fn clause_12_packages_omitted() { - // The Python fixture has no packages, but we also verify the word doesn't - // appear as an attribute or block. +fn clause_12_packages_render_as_div_attribute() { + // The Quarto filter reads a comma-separated `packages` attribute on the + // blendtutor div and preloads them in webR/Pyodide. + let yaml = "\ +lesson_name: \"Pkg\" +language: Python +packages: + - pandas + - numpy +exercise: + prompt: \"Write add.\" + llm_evaluation_prompt: \"Grade this: {student_code}\" +"; + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all(yaml.as_bytes()).unwrap(); + + let stdout = export_stdout(file.path().to_str().unwrap()); + assert!( + stdout.starts_with("::: {.blendtutor language=\"python\" packages=\"pandas,numpy\"}\n"), + "packages should ride the opening div as an attribute, got:\n{stdout}" + ); +} + +#[test] +fn clause_12b_no_packages_attribute_when_list_is_empty() { let stdout = export_stdout(PYTHON_LESSON); assert!( - !stdout.contains("packages"), - "packages must be OMITTED from output, got:\n{stdout}" + !stdout.contains("packages="), + "no packages attribute for a lesson without packages, got:\n{stdout}" ); } From ce7094bdeed87966a2a6ab462f2d14d2a52d0903 Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 13:29:02 -0600 Subject: [PATCH 02/27] feat: export-quarto renders gotchas div and packages attribute Gotchas become a nested ::: {.gotchas} div and packages a comma-separated packages="..." attribute on the opening div, matching what blendtutor.lua already parses. Absent fields still emit nothing. --- crates/core/src/quarto_export.rs | 54 +++++++++++++++++++------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/crates/core/src/quarto_export.rs b/crates/core/src/quarto_export.rs index c7a3411..a2f0d61 100644 --- a/crates/core/src/quarto_export.rs +++ b/crates/core/src/quarto_export.rs @@ -14,9 +14,9 @@ //! | `exercise.solution` | ```` ```{. .solution} ```` block (if `Some`) | //! | `exercise.hints` | `::: {.hints}` div (if `Some`) | //! | `lesson.language` | `language=""` attribute | -//! | `exercise.gotchas` | EXCLUDED (no `.qmd` equivalent) | +//! | `exercise.gotchas` | `::: {.gotchas}` div (if `Some`) | +//! | `lesson.packages` | `packages="a,b"` attribute (if non-empty) | //! | `exercise.llm_evaluation_prompt` | EXCLUDED (author-only, ADR-0006) | -//! | `lesson.packages` | OMITTED (out-of-scope per decomposition) | use crate::lesson::{Language, Lesson}; @@ -27,10 +27,10 @@ const MIN_FENCE_LEN: usize = 3; /// /// The output is a self-contained block starting with /// `::: {.blendtutor language=""}` and closing with `:::`. Each -/// optional section (code template, checks, solution, hints) is emitted only -/// when the corresponding field is present, so no empty blocks appear for -/// absent fields (§1.1). Author-only fields (`llm_evaluation_prompt`, -/// `gotchas`) and out-of-scope fields (`packages`) are excluded. +/// optional section (code template, checks, solution, hints, gotchas) and the +/// `packages` attribute are emitted only when the corresponding field is +/// present, so no empty blocks appear for absent fields (§1.1). The +/// author-only `llm_evaluation_prompt` is excluded (ADR-0006). /// /// # Arguments /// * `lesson` — A valid, parsed lesson (constructed via [`Lesson::parse`]). @@ -42,8 +42,16 @@ pub fn export_lesson_to_qmd(lesson: &Lesson) -> String { let lang = language_tag(&lesson.language); let mut out = String::new(); - // Opening div with the language attribute. - out.push_str(&format!("::: {{.blendtutor language=\"{lang}\"}}\n")); + // Opening div with the language attribute, plus the comma-separated + // packages attribute the Quarto filter splits (`parse_packages`). + let packages = if lesson.packages.is_empty() { + String::new() + } else { + format!(" packages=\"{}\"", lesson.packages.join(",")) + }; + out.push_str(&format!( + "::: {{.blendtutor language=\"{lang}\"{packages}}}\n" + )); // Prompt as prose (always present — it is a required field). out.push_str(lesson.exercise.prompt.trim_end()); @@ -92,6 +100,15 @@ pub fn export_lesson_to_qmd(lesson: &Lesson) -> String { out.push_str(":::\n"); } + // Gotchas as a fenced div (if present). + if let Some(ref gotchas) = lesson.exercise.gotchas { + out.push('\n'); + out.push_str("::: {.gotchas}\n"); + out.push_str(gotchas.trim_end()); + out.push('\n'); + out.push_str(":::\n"); + } + // Closing div. out.push_str(":::\n"); @@ -187,7 +204,7 @@ exercise: } #[test] - fn export_excludes_gotchas() { + fn export_renders_gotchas_as_gotchas_div() { let yaml = r#" lesson_name: "Gotchas" language: R @@ -200,22 +217,19 @@ exercise: let lesson = Lesson::parse(yaml).unwrap(); let qmd = export_lesson_to_qmd(&lesson); assert!( - !qmd.contains("gotchas"), - "gotchas must be absent, got:\n{qmd}" - ); - assert!( - !qmd.contains("R uses '<-' for assignment"), - "gotchas text must be absent, got:\n{qmd}" + qmd.contains("::: {.gotchas}\n- R uses '<-' for assignment.\n:::\n"), + "gotchas should render as a closed ::: {{.gotchas}} div, got:\n{qmd}" ); } #[test] - fn export_omits_packages() { + fn export_renders_packages_as_comma_separated_attribute() { let yaml = r#" lesson_name: "Pkg" language: Python packages: - pandas + - numpy exercise: prompt: "Write add." llm_evaluation_prompt: "Grade this: {student_code}" @@ -223,12 +237,8 @@ exercise: let lesson = Lesson::parse(yaml).unwrap(); let qmd = export_lesson_to_qmd(&lesson); assert!( - !qmd.contains("packages"), - "packages must be omitted, got:\n{qmd}" - ); - assert!( - !qmd.contains("pandas"), - "package names must be omitted, got:\n{qmd}" + qmd.starts_with("::: {.blendtutor language=\"python\" packages=\"pandas,numpy\"}\n"), + "packages should be a comma-separated div attribute, got:\n{qmd}" ); } From e3974ce25eff535920bdb2f63bf758f5a6592fd4 Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 13:32:55 -0600 Subject: [PATCH 03/27] fix(review): reject package names that break comma-separated lists A packages entry containing a quote, comma, or whitespace (or an empty one) would corrupt the exported packages="a,b" div attribute and the uv run --with list. Lesson::parse now refuses it with InvalidPackageName; version specifiers like numpy>=2 stay valid. Also asserts no gotchas div is emitted when gotchas are absent. --- crates/core/src/lesson.rs | 66 ++++++++++++++++++++++++++++++-- crates/core/src/quarto_export.rs | 4 ++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/crates/core/src/lesson.rs b/crates/core/src/lesson.rs index dbabae0..6056c66 100644 --- a/crates/core/src/lesson.rs +++ b/crates/core/src/lesson.rs @@ -159,6 +159,14 @@ pub enum ValidationError { /// The name of the field with malformed bullets (e.g. `"gotchas"`). field: String, }, + /// A `packages` entry is empty or contains a double quote, a comma, or + /// whitespace. Every consumer joins or splits the list on commas (the + /// Quarto `packages="a,b"` attribute, `uv run --with`), so such a name + /// would silently become a different package list or break the attribute. + InvalidPackageName { + /// The offending entry, verbatim. + name: String, + }, } impl fmt::Display for ValidationError { @@ -176,6 +184,11 @@ impl fmt::Display for ValidationError { "exercise.{field} must be bullet-formatted: each non-empty line must \ start with `- ` or `* `" ), + ValidationError::InvalidPackageName { name } => write!( + f, + "packages entry {name:?} is invalid: package names must be non-empty \ + and contain no quotes, commas, or whitespace" + ), } } } @@ -202,6 +215,23 @@ fn validate_bullet_format(field: &str, content: &str) -> Result<(), ValidationEr Ok(()) } +/// Validate that one `packages` entry survives being joined into a +/// comma-separated list: non-empty, with no `"`, `,`, or whitespace. Version +/// specifiers such as `pandas>=2` stay valid. +fn validate_package_name(name: &str) -> Result<(), ValidationError> { + let list_safe = !name.is_empty() + && !name + .chars() + .any(|c| c == '"' || c == ',' || c.is_whitespace()); + if list_safe { + Ok(()) + } else { + Err(ValidationError::InvalidPackageName { + name: name.to_string(), + }) + } +} + impl Lesson { /// Parse a lesson from a YAML document. /// @@ -219,10 +249,10 @@ impl Lesson { /// Enforce the semantic rules that structure alone cannot. /// - /// Currently three rules: the evaluation prompt must contain the - /// `{student_code}` placeholder, `exercise.gotchas` (if present) must be - /// bullet-formatted, and `exercise.hints` (if present) must be - /// bullet-formatted. Split from the structural deserialize so each name + /// Currently four rules: the evaluation prompt must contain the + /// `{student_code}` placeholder, `exercise.gotchas` and `exercise.hints` + /// (if present) must be bullet-formatted, and every `packages` entry must + /// be a single list-safe name. Split from the structural deserialize so each name /// covers its body (§5.1). fn validate_semantics(&self) -> Result<(), ValidationError> { if !self @@ -238,6 +268,9 @@ impl Lesson { if let Some(ref hints) = self.exercise.hints { validate_bullet_format("hints", hints)?; } + for name in &self.packages { + validate_package_name(name)?; + } Ok(()) } } @@ -833,4 +866,29 @@ exercise: "Read should expose the io::Error as its source" ); } + + fn lesson_with_packages(entries: &str) -> String { + format!( + "lesson_name: \"Pkg\"\nlanguage: Python\npackages: {entries}\nexercise:\n prompt: \"Write add.\"\n llm_evaluation_prompt: \"Grade: {{student_code}}\"\n" + ) + } + + #[test] + fn parse_accepts_plain_and_versioned_package_names() { + let lesson = Lesson::parse(&lesson_with_packages("[pandas, 'numpy>=2', purrr]")) + .expect("plain and versioned names are list-safe"); + assert_eq!(lesson.packages, vec!["pandas", "numpy>=2", "purrr"]); + } + + #[test] + fn parse_rejects_package_names_that_break_comma_lists() { + for bad in [r#"['foo"bar']"#, "['a,b']", "['has space']", "['']"] { + let err = Lesson::parse(&lesson_with_packages(bad)) + .expect_err(&format!("{bad} must be rejected")); + assert!( + matches!(err, ValidationError::InvalidPackageName { .. }), + "expected InvalidPackageName for {bad}, got {err:?}" + ); + } + } } diff --git a/crates/core/src/quarto_export.rs b/crates/core/src/quarto_export.rs index a2f0d61..ff7ee60 100644 --- a/crates/core/src/quarto_export.rs +++ b/crates/core/src/quarto_export.rs @@ -265,6 +265,10 @@ exercise: !qmd.contains("{.hints}"), "no hints div for absent hints, got:\n{qmd}" ); + assert!( + !qmd.contains("{.gotchas}"), + "no gotchas div for absent gotchas, got:\n{qmd}" + ); } #[test] From cc3aefc76081fabab2910d49a2e311990109ea60 Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 13:33:57 -0600 Subject: [PATCH 04/27] test(red): export-quarto --document and --key-page produce renderable pages An R lesson exported with --document opens with title, blendtutor filter and coi front matter (plus a book-mode COI warning); Python omits coi; titles are YAML-escaped. --key-page prints a full page with the .blendtutor-key mount div that matches demo-book/api-key.qmd. --key-page and a lesson path are mutually exclusive. --- crates/cli/tests/export_quarto.rs | 118 ++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/crates/cli/tests/export_quarto.rs b/crates/cli/tests/export_quarto.rs index 0146dc9..b3d7f8f 100644 --- a/crates/cli/tests/export_quarto.rs +++ b/crates/cli/tests/export_quarto.rs @@ -373,3 +373,121 @@ exercise: "output must end with ::: despite ::: in prompt, got last line: {last_line:?}" ); } + +// ── ADR-0019: --document shape and --key-page export ──────────────────────── + +/// Run `blendtutor export-quarto ` and return the raw output. +fn export_output(args: &[&str]) -> std::process::Output { + Command::cargo_bin("blendtutor") + .unwrap() + .arg("export-quarto") + .args(args) + .output() + .unwrap() +} + +/// Stdout of a successful `export-quarto ` run. +fn export_success(args: &[&str]) -> String { + let output = export_output(args); + assert!( + output.status.success(), + "export-quarto {args:?} should exit 0, got {:?}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("stdout should be UTF-8") +} + +#[test] +fn document_r_lesson_is_a_renderable_page_with_filter_and_coi() { + let stdout = export_success(&["--document", R_LESSON_FULL]); + assert!( + stdout + .starts_with("---\ntitle: \"Add Two Numbers\"\nfilters:\n - mcmullarkey/blendtutor\n"), + "document should open with title + filter front matter, got:\n{stdout}" + ); + assert!( + stdout.contains("\ncoi: true\n"), + "R documents need cross-origin isolation for webR, got:\n{stdout}" + ); + assert!( + stdout.contains("type: book"), + "R documents should warn that COI does not work in book projects, got:\n{stdout}" + ); + let body = stdout.split("\n---\n").nth(1).unwrap_or(""); + assert!( + body.contains("::: {.blendtutor language=\"r\"}"), + "the exercise div should follow the front matter, got:\n{stdout}" + ); +} + +#[test] +fn document_python_lesson_omits_coi() { + let stdout = export_success(&["--document", PYTHON_LESSON]); + assert!(stdout.starts_with("---\n"), "got:\n{stdout}"); + assert!( + !stdout.contains("coi:"), + "Pyodide needs no cross-origin isolation, got:\n{stdout}" + ); +} + +#[test] +fn document_title_escapes_yaml_special_characters() { + let yaml = "\ +lesson_name: 'Say \"hi\" \\\\ bye' +language: Python +exercise: + prompt: \"Write add.\" + llm_evaluation_prompt: \"Grade this: {student_code}\" +"; + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all(yaml.as_bytes()).unwrap(); + let stdout = export_success(&["--document", file.path().to_str().unwrap()]); + assert!( + stdout.contains("title: \"Say \\\"hi\\\" \\\\\\\\ bye\"\n"), + "quotes and backslashes in the title must be YAML-escaped, got:\n{stdout}" + ); +} + +#[test] +fn key_page_is_a_complete_page_with_the_key_mount_div() { + let stdout = export_success(&["--key-page"]); + assert!( + stdout.starts_with("---\ntitle: \"API Key\"\nfilters:\n - mcmullarkey/blendtutor\n---\n"), + "key page should carry title + filter front matter, got:\n{stdout}" + ); + assert!( + stdout.contains("\n::: {.blendtutor-key}\n"), + "key page must contain the key mount div, got:\n{stdout}" + ); +} + +#[test] +fn key_page_mount_div_matches_the_demo_book_copy() { + // Drift guard (ADR-0019): the runtime mounts on this exact div, so the + // exported page and the demo book's hand-written page must agree on it. + let demo = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../demo-book/api-key.qmd" + )) + .unwrap(); + let exported = export_success(&["--key-page"]); + for page in [&demo, &exported] { + assert!(page.contains("\n::: {.blendtutor-key}\n"), "got:\n{page}"); + } +} + +#[test] +fn key_page_and_lesson_are_mutually_exclusive() { + assert!( + !export_output(&["--key-page", R_LESSON_FULL]) + .status + .success() + ); + assert!(!export_output(&[]).status.success()); + assert!( + !export_output(&["--key-page", "--document"]) + .status + .success() + ); +} From 4f99486288da92e692eb4c5ec064a3e4e5e3c911 Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 13:35:52 -0600 Subject: [PATCH 05/27] docs: ADR-0019 export-quarto document shape and key-page export --- ...019-export-quarto-document-and-key-page.md | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/adr/0019-export-quarto-document-and-key-page.md diff --git a/docs/adr/0019-export-quarto-document-and-key-page.md b/docs/adr/0019-export-quarto-document-and-key-page.md new file mode 100644 index 0000000..f08d1be --- /dev/null +++ b/docs/adr/0019-export-quarto-document-and-key-page.md @@ -0,0 +1,64 @@ +# ADR-0019: `export-quarto` document shape and key-page export + +- Status: Accepted +- Date: 2026-09-13 + +## Context + +`blendtutor export-quarto ` prints a bare `::: {.blendtutor}` div +(ADR-0017 distribution, `core::quarto_export`). Authors pasting that snippet +into their own Quarto project hit three silent failures the snippet cannot +warn about: + +- No `filters: [mcmullarkey/blendtutor]` → the div renders as inert prose. +- R exercises without `coi: true` → webR never boots (ADR-0015). +- No page carrying `::: {.blendtutor-key}` → authors copy the demo book's + `api-key.qmd` by hand (the inline no-key form from issue #186 still works, + but there is no shareable key-management page). + +The export must be able to produce a *renderable* page, and a key page, without +breaking the existing snippet contract that docs and tests depend on. + +## Options + +1. **Documentation only.** Extend README/whole-game with the missing + front matter. No interface change, but the failures stay silent and every + author re-derives the YAML by hand. +2. **Always emit front matter.** Renderable by default, but breaks the + paste-into-an-existing-page workflow (a second YAML header mid-document is + literal text) and every existing snippet test. +3. **Opt-in shapes on the same command.** `export-quarto ` keeps the + snippet; `--document` wraps it with front matter; `--key-page` (mutually + exclusive with a lesson path, enforced by clap) prints a complete key page. + Core models the shape as a sum type, not a bool. + +## Decision + +Option 3. + +- **Shape is a type (§1.2).** `core::quarto_export::ExportShape { Snippet, + Document }`; `export_lesson_to_qmd(&Lesson, ExportShape)` stays pure (§2.1). +- **Document front matter.** `title` (from `lesson_name`), + `filters: [mcmullarkey/blendtutor]`, and `coi: true` for R lessons only + (Pyodide needs no isolation, ADR-0015). R documents carry a YAML comment that + COI does not function in `type: book` projects (README limitation) so the + author learns it at export time, not after deploy. +- **Key page.** `core::quarto_export::key_page_qmd()` is a pure constant-backed + function returning a complete page: front matter with the filter, the + `::: {.blendtutor-key}` mount div, and the storage/HTTP-serving notes + mirrored from `demo-book/api-key.qmd`. Its file name must match the filter's + `bt-key-page` default (`api-key.html`), so the CLI help names `api-key.qmd`. +- **CLI boundary (§1.3).** clap `ArgGroup` requires exactly one of `` + or `--key-page`; `--document` requires ``. Invalid combinations fail + at parse time, never inside core. + +## Consequences + +- The bare snippet output is byte-identical to before for existing callers. +- Project-level filters: a book that already lists the filter in + `_quarto.yml` should use the snippet (or delete the page-level `filters`), + since Quarto merges both lists. Documented in the book guide. +- The key-page text now exists in two places (`demo-book/api-key.qmd` and + `key_page_qmd`). An integration test renders the exported page's mount div + and asserts the demo copy keeps the same div, so the two cannot drift on the + part the runtime depends on. From fe76fd4105d87627353038dd834c9be84993de15 Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 13:40:45 -0600 Subject: [PATCH 06/27] feat: export-quarto --document and --key-page print renderable pages core::quarto_export gains ExportShape { Snippet, Document } and key_page_qmd(). --document prefixes title, blendtutor filter and (R only) coi: true plus a book-mode COI note; titles are YAML-escaped. --key-page prints a complete page with the .blendtutor-key mount div. clap enforces exactly one of or --key-page, and --document conflicts with --key-page. --- crates/cli/src/commands/export_quarto.rs | 44 +++++-- crates/cli/src/main.rs | 37 +++++- crates/core/src/quarto_export.rs | 154 +++++++++++++++++++++-- 3 files changed, 208 insertions(+), 27 deletions(-) diff --git a/crates/cli/src/commands/export_quarto.rs b/crates/cli/src/commands/export_quarto.rs index f3ec5f7..2512aae 100644 --- a/crates/cli/src/commands/export_quarto.rs +++ b/crates/cli/src/commands/export_quarto.rs @@ -1,25 +1,50 @@ -//! `blendtutor export-quarto ` — convert a lesson YAML file to a -//! Quarto `.qmd` fenced-div snippet on stdout. +//! `blendtutor export-quarto` — print Quarto source on stdout: a lesson as a +//! fenced-div snippet or complete page, or the API key page (ADR-0019). //! -//! A thin effectful shell (§2.2): read the file, delegate to the pure -//! [`blendtutor_core::quarto_export::export_lesson_to_qmd`] transform, write +//! A thin effectful shell (§2.2): read the lesson file when there is one, +//! delegate to the pure [`blendtutor_core::quarto_export`] transforms, write //! the result to stdout, and return the exit code. No domain logic lives //! here — that is `core`'s responsibility. -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::ExitCode; use blendtutor_core::lesson::{LoadError, read_lesson_file}; -use blendtutor_core::quarto_export::export_lesson_to_qmd; +use blendtutor_core::quarto_export::{ExportShape, export_lesson_to_qmd, key_page_qmd}; -/// Load the lesson at `path`, transform it to a `.qmd` snippet, and write it +/// What the author asked `export-quarto` to print. Built from the clap flags +/// in `main`, where clap has already refused invalid combinations (§1.3). +pub enum ExportRequest { + /// Export the lesson at `path` in the given shape. + Lesson { + /// Path to the lesson YAML file. + path: PathBuf, + /// Snippet or complete page. + shape: ExportShape, + }, + /// Print the static API key page. + KeyPage, +} + +/// Print the Quarto source for `request` to stdout. +pub fn run(request: ExportRequest) -> anyhow::Result { + match request { + ExportRequest::Lesson { path, shape } => export_lesson(&path, shape), + ExportRequest::KeyPage => { + print!("{}", key_page_qmd()); + Ok(ExitCode::SUCCESS) + } + } +} + +/// Load the lesson at `path`, transform it to `.qmd` in `shape`, and write it /// to stdout. /// /// A read failure (missing file, bad permissions) propagates to `main` as an /// error. An invalid lesson (failed validation) prints the error to stderr /// and returns a nonzero exit code — distinct from a read error, which is an /// `anyhow` error. -pub fn run(path: &Path) -> anyhow::Result { +fn export_lesson(path: &Path, shape: ExportShape) -> anyhow::Result { let lesson = match read_lesson_file(path) { Ok(lesson) => lesson, Err(LoadError::Invalid(error)) => { @@ -28,7 +53,6 @@ pub fn run(path: &Path) -> anyhow::Result { } Err(LoadError::Read(error)) => return Err(error.into()), }; - let qmd = export_lesson_to_qmd(&lesson); - print!("{qmd}"); + print!("{}", export_lesson_to_qmd(&lesson, shape)); Ok(ExitCode::SUCCESS) } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 08d916b..4f16da6 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -6,14 +6,16 @@ use std::path::PathBuf; use std::process::ExitCode; -use clap::{Parser, Subcommand}; +use clap::{ArgGroup, Parser, Subcommand}; use blendtutor_core::lesson::Language; +use blendtutor_core::quarto_export::ExportShape; use blendtutor_core::site::BuildTarget; mod commands; mod output; +use commands::export_quarto::ExportRequest; use output::OutputFormat; /// Author and run interactive R and Python coding lessons with LLM feedback. @@ -112,10 +114,19 @@ enum Commands { #[arg(long)] embed_key: Option, }, - /// Export a lesson YAML file to a Quarto `.qmd` fenced-div snippet. + /// Export a lesson YAML file to a Quarto `.qmd` fenced-div snippet, a + /// complete page (`--document`), or print the API key page (`--key-page`). + #[command(group(ArgGroup::new("source").required(true).args(["lesson", "key_page"])))] ExportQuarto { /// Path to the lesson YAML file. - lesson: PathBuf, + lesson: Option, + /// Prefix the exercise with front matter (title, blendtutor filter, and + /// `coi: true` for R) so the page renders on its own. + #[arg(long, requires = "lesson", conflicts_with = "key_page")] + document: bool, + /// Print a complete API key page; save it as `api-key.qmd`. + #[arg(long)] + key_page: bool, }, } @@ -134,6 +145,22 @@ enum NewTarget { }, } +/// Turn the `export-quarto` flags into a request. clap's `source` group +/// guarantees a missing lesson path means `--key-page` was given. +fn export_request(lesson: Option, document: bool) -> ExportRequest { + match lesson { + Some(path) => ExportRequest::Lesson { + path, + shape: if document { + ExportShape::Document + } else { + ExportShape::Snippet + }, + }, + None => ExportRequest::KeyPage, + } +} + fn main() -> anyhow::Result { let cli = Cli::parse(); match cli.command { @@ -168,6 +195,8 @@ fn main() -> anyhow::Result { password.as_deref(), embed_key.as_deref(), ), - Commands::ExportQuarto { lesson } => commands::export_quarto::run(&lesson), + Commands::ExportQuarto { + lesson, document, .. + } => commands::export_quarto::run(export_request(lesson, document)), } } diff --git a/crates/core/src/quarto_export.rs b/crates/core/src/quarto_export.rs index ff7ee60..13f47aa 100644 --- a/crates/core/src/quarto_export.rs +++ b/crates/core/src/quarto_export.rs @@ -1,4 +1,5 @@ -//! Pure transform: [`Lesson`] → Quarto `.qmd` fenced-div snippet. +//! Pure transforms: [`Lesson`] → Quarto `.qmd` fenced-div snippet or complete +//! page, plus the static API key page (ADR-0019). //! //! The conversion is a pure function — no I/O, no side effects, deterministic //! (§2.1). The CLI command in `blendtutor-cli` is the thin effectful shell that @@ -20,6 +21,68 @@ use crate::lesson::{Language, Lesson}; +/// What `export_lesson_to_qmd` produces (ADR-0019). +/// +/// A sum type rather than a bool so each call site names the shape it wants +/// (§1.2). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExportShape { + /// The bare `::: {.blendtutor}` div, for pasting into an existing page. + Snippet, + /// The div preceded by front matter, so the page renders on its own. + Document, +} + +/// The filter reference every exported page declares. +const FILTER_NAME: &str = "mcmullarkey/blendtutor"; + +/// Front-matter comment for R documents: webR needs cross-origin isolation, +/// which the README documents as non-functional in book projects. +const R_BOOK_COI_NOTE: &str = "\ +# R exercises run in webR, which needs cross-origin isolation (coi: true). +# COI does not function in Quarto `type: book` projects; render this page as a +# standalone document for runnable R. +"; + +/// A complete API key page (ADR-0019), mirroring `demo-book/api-key.qmd`. +const KEY_PAGE_QMD: &str = r#"--- +title: "API Key" +filters: + - mcmullarkey/blendtutor +--- + +# API Key + +AI-powered feedback on these exercises uses the [Fireworks AI](https://fireworks.ai) +API with your own key. Create a key in the +[Fireworks console](https://app.fireworks.ai/account/keys); keys look like +`fw_...`. + +## Enter your key + +::: {.blendtutor-key} + +Loading API key settings… + +::: + +Your key is stored **only** in this browser's `localStorage`, shared across +every page of the site, and sent **only** in an `Authorization: Bearer` header +to `api.fireworks.ai`. + +## Serve over HTTP + +`localStorage` and JavaScript ES modules are blocked for pages opened from the +local filesystem (`file://`). Preview with `quarto preview`, or serve the +rendered output directory over HTTP. +"#; + +/// The complete API key page, ready to save as `api-key.qmd` (the filter's +/// default `bt-key-page` target is `api-key.html`). +pub fn key_page_qmd() -> &'static str { + KEY_PAGE_QMD +} + /// The minimum fence length for a fenced code block (CommonMark default). const MIN_FENCE_LEN: usize = 3; @@ -32,15 +95,21 @@ const MIN_FENCE_LEN: usize = 3; /// present, so no empty blocks appear for absent fields (§1.1). The /// author-only `llm_evaluation_prompt` is excluded (ADR-0006). /// +/// With [`ExportShape::Document`] the div is preceded by [`front_matter`]. +/// /// # Arguments /// * `lesson` — A valid, parsed lesson (constructed via [`Lesson::parse`]). +/// * `shape` — Snippet or complete page. /// /// # Returns /// A `String` containing the `.qmd` fenced-div snippet, terminated by a /// newline. -pub fn export_lesson_to_qmd(lesson: &Lesson) -> String { +pub fn export_lesson_to_qmd(lesson: &Lesson, shape: ExportShape) -> String { let lang = language_tag(&lesson.language); - let mut out = String::new(); + let mut out = match shape { + ExportShape::Snippet => String::new(), + ExportShape::Document => front_matter(lesson), + }; // Opening div with the language attribute, plus the comma-separated // packages attribute the Quarto filter splits (`parse_packages`). @@ -115,6 +184,29 @@ pub fn export_lesson_to_qmd(lesson: &Lesson) -> String { out } +/// Render the YAML front matter that makes an exported lesson a standalone page: +/// title, the blendtutor filter, and — for R only — `coi: true` with a note +/// that COI does not work in book projects (ADR-0015, ADR-0019). +fn front_matter(lesson: &Lesson) -> String { + let title = yaml_double_quoted(&lesson.lesson_name.to_string()); + let coi = match lesson.language { + Language::R => format!("coi: true\n{R_BOOK_COI_NOTE}"), + Language::Python => String::new(), + }; + format!("---\ntitle: {title}\nfilters:\n - {FILTER_NAME}\n{coi}---\n\n") +} + +/// Quote `value` as a YAML double-quoted scalar, escaping backslashes, quotes, +/// and line breaks so author text can never end the scalar early. +fn yaml_double_quoted(value: &str) -> String { + let escaped = value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r"); + format!("\"{escaped}\"") +} + /// Map a [`Language`] to its lowercase code-fence tag. /// /// `R` → `"r"`, `Python` → `"python"`. Lowercase matches Pandoc/Quarto @@ -172,7 +264,7 @@ exercise: #[test] fn export_opens_with_blendtutor_div_and_language() { let lesson = Lesson::parse(VALID_YAML).unwrap(); - let qmd = export_lesson_to_qmd(&lesson); + let qmd = export_lesson_to_qmd(&lesson, ExportShape::Snippet); assert!( qmd.starts_with("::: {.blendtutor language=\"r\"}\n"), "should open with the blendtutor div, got:\n{qmd}" @@ -182,7 +274,7 @@ exercise: #[test] fn export_closes_with_div_marker() { let lesson = Lesson::parse(VALID_YAML).unwrap(); - let qmd = export_lesson_to_qmd(&lesson); + let qmd = export_lesson_to_qmd(&lesson, ExportShape::Snippet); assert!( qmd.trim_end().ends_with(":::"), "should close with :::, got:\n{qmd}" @@ -192,7 +284,7 @@ exercise: #[test] fn export_excludes_llm_evaluation_prompt() { let lesson = Lesson::parse(VALID_YAML).unwrap(); - let qmd = export_lesson_to_qmd(&lesson); + let qmd = export_lesson_to_qmd(&lesson, ExportShape::Snippet); assert!( !qmd.contains("llm_evaluation_prompt"), "llm_evaluation_prompt must be absent, got:\n{qmd}" @@ -215,7 +307,7 @@ exercise: llm_evaluation_prompt: "Grade this: {student_code}" "#; let lesson = Lesson::parse(yaml).unwrap(); - let qmd = export_lesson_to_qmd(&lesson); + let qmd = export_lesson_to_qmd(&lesson, ExportShape::Snippet); assert!( qmd.contains("::: {.gotchas}\n- R uses '<-' for assignment.\n:::\n"), "gotchas should render as a closed ::: {{.gotchas}} div, got:\n{qmd}" @@ -235,7 +327,7 @@ exercise: llm_evaluation_prompt: "Grade this: {student_code}" "#; let lesson = Lesson::parse(yaml).unwrap(); - let qmd = export_lesson_to_qmd(&lesson); + let qmd = export_lesson_to_qmd(&lesson, ExportShape::Snippet); assert!( qmd.starts_with("::: {.blendtutor language=\"python\" packages=\"pandas,numpy\"}\n"), "packages should be a comma-separated div attribute, got:\n{qmd}" @@ -252,7 +344,7 @@ exercise: llm_evaluation_prompt: "Grade this: {student_code}" "#; let lesson = Lesson::parse(yaml).unwrap(); - let qmd = export_lesson_to_qmd(&lesson); + let qmd = export_lesson_to_qmd(&lesson, ExportShape::Snippet); assert!( !qmd.contains(".solution"), "no .solution block for absent solution, got:\n{qmd}" @@ -282,7 +374,7 @@ exercise: llm_evaluation_prompt: "Grade this: {student_code}" "#; let lesson = Lesson::parse(yaml).unwrap(); - let qmd = export_lesson_to_qmd(&lesson); + let qmd = export_lesson_to_qmd(&lesson, ExportShape::Snippet); assert!( qmd.contains("language=\"python\""), "Python lesson should use language=\"python\", got:\n{qmd}" @@ -306,7 +398,7 @@ exercise: llm_evaluation_prompt: "Grade this: {student_code}" "#; let lesson = Lesson::parse(yaml).unwrap(); - let qmd = export_lesson_to_qmd(&lesson); + let qmd = export_lesson_to_qmd(&lesson, ExportShape::Snippet); assert!( qmd.contains("````r\n"), "fence should be 4 backticks when content has ```, got:\n{qmd}" @@ -325,8 +417,8 @@ exercise: "#; let lesson_b = Lesson::parse(yaml_b).unwrap(); assert_ne!( - export_lesson_to_qmd(&lesson_a), - export_lesson_to_qmd(&lesson_b), + export_lesson_to_qmd(&lesson_a, ExportShape::Snippet), + export_lesson_to_qmd(&lesson_b, ExportShape::Snippet), "different lessons must produce different output" ); } @@ -358,4 +450,40 @@ exercise: assert_eq!(longest_backtick_run("triple ``` here"), 3); assert_eq!(longest_backtick_run("`` and ``` mixed"), 3); } + + #[test] + fn document_shape_prefixes_front_matter_and_keeps_the_snippet_intact() { + let lesson = Lesson::parse(VALID_YAML).unwrap(); + let snippet = export_lesson_to_qmd(&lesson, ExportShape::Snippet); + let document = export_lesson_to_qmd(&lesson, ExportShape::Document); + assert_eq!(document, format!("{}{snippet}", front_matter(&lesson))); + } + + #[test] + fn front_matter_adds_coi_only_for_r() { + let r = Lesson::parse(VALID_YAML).unwrap(); + assert!(front_matter(&r).contains("\ncoi: true\n")); + let py = Lesson::parse( + "lesson_name: \"Py\"\nlanguage: Python\nexercise:\n prompt: \"p\"\n llm_evaluation_prompt: \"{student_code}\"\n", + ) + .unwrap(); + assert!(!front_matter(&py).contains("coi")); + } + + #[test] + fn yaml_double_quoted_escapes_scalar_terminators() { + assert_eq!(yaml_double_quoted("plain"), "\"plain\""); + assert_eq!(yaml_double_quoted("a\"b\\c\nd"), "\"a\\\"b\\\\c\\nd\""); + } + + #[test] + fn key_page_has_front_matter_and_mount_div() { + let page = key_page_qmd(); + assert!( + page.starts_with( + "---\ntitle: \"API Key\"\nfilters:\n - mcmullarkey/blendtutor\n---\n" + ) + ); + assert!(page.contains("\n::: {.blendtutor-key}\n")); + } } From f01718ebfea76d761ce7dd5ffbb8007ad51a0112 Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 13:42:58 -0600 Subject: [PATCH 07/27] test(red): export-quarto warns when a lesson has no checks, solution, or hints Such an exercise renders with only Run and LLM feedback, which authors mistake for a broken widget. The warning goes to stderr so piping stdout into a .qmd stays clean; lessons with any learner aid and --key-page stay silent. --- crates/cli/tests/export_quarto.rs | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/cli/tests/export_quarto.rs b/crates/cli/tests/export_quarto.rs index b3d7f8f..a723658 100644 --- a/crates/cli/tests/export_quarto.rs +++ b/crates/cli/tests/export_quarto.rs @@ -491,3 +491,40 @@ fn key_page_and_lesson_are_mutually_exclusive() { .success() ); } + +// ── Thin-lesson warning ─────────────────────────────────────────────────────── + +#[test] +fn lesson_without_checks_solution_or_hints_warns_on_stderr() { + let output = export_output(&[R_LESSON_MINIMAL]); + assert!(output.status.success(), "a thin lesson still exports"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.starts_with("warning:"), + "a lesson with no learner aids should warn, got stderr:\n{stderr}" + ); + for field in ["checks", "solution", "hints"] { + assert!( + stderr.contains(field), + "the warning should name the missing `{field}`, got:\n{stderr}" + ); + } + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.starts_with("::: {.blendtutor language=\"r\"}"), + "the warning must not leak into stdout, got:\n{stdout}" + ); +} + +#[test] +fn lesson_with_learner_aids_or_key_page_exports_silently() { + for args in [vec![R_LESSON_FULL], vec![PYTHON_LESSON], vec!["--key-page"]] { + let output = export_output(&args); + assert!(output.status.success()); + assert!( + output.stderr.is_empty(), + "export-quarto {args:?} should not warn, got stderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + } +} From 4df77c8f309b17bc1fdbfa3d80192d68c6807af7 Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 13:51:52 -0600 Subject: [PATCH 08/27] feat: export-quarto warns on stderr when a lesson has no learner aids core::quarto_export::thin_lesson_warning returns a message when a lesson has no checks, solution, or hints; the CLI prints it to stderr before writing the unchanged .qmd to stdout. --- crates/cli/src/commands/export_quarto.rs | 9 ++++- crates/core/src/quarto_export.rs | 51 ++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/crates/cli/src/commands/export_quarto.rs b/crates/cli/src/commands/export_quarto.rs index 2512aae..5709a23 100644 --- a/crates/cli/src/commands/export_quarto.rs +++ b/crates/cli/src/commands/export_quarto.rs @@ -10,7 +10,9 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; use blendtutor_core::lesson::{LoadError, read_lesson_file}; -use blendtutor_core::quarto_export::{ExportShape, export_lesson_to_qmd, key_page_qmd}; +use blendtutor_core::quarto_export::{ + ExportShape, export_lesson_to_qmd, key_page_qmd, thin_lesson_warning, +}; /// What the author asked `export-quarto` to print. Built from the clap flags /// in `main`, where clap has already refused invalid combinations (§1.3). @@ -38,7 +40,7 @@ pub fn run(request: ExportRequest) -> anyhow::Result { } /// Load the lesson at `path`, transform it to `.qmd` in `shape`, and write it -/// to stdout. +/// to stdout, printing any thin-lesson warning to stderr first. /// /// A read failure (missing file, bad permissions) propagates to `main` as an /// error. An invalid lesson (failed validation) prints the error to stderr @@ -53,6 +55,9 @@ fn export_lesson(path: &Path, shape: ExportShape) -> anyhow::Result { } Err(LoadError::Read(error)) => return Err(error.into()), }; + if let Some(warning) = thin_lesson_warning(&lesson) { + eprintln!("{warning}"); + } print!("{}", export_lesson_to_qmd(&lesson, shape)); Ok(ExitCode::SUCCESS) } diff --git a/crates/core/src/quarto_export.rs b/crates/core/src/quarto_export.rs index 13f47aa..0f38855 100644 --- a/crates/core/src/quarto_export.rs +++ b/crates/core/src/quarto_export.rs @@ -184,6 +184,26 @@ pub fn export_lesson_to_qmd(lesson: &Lesson, shape: ExportShape) -> String { out } +/// Warn when `lesson` carries none of the aids that make the Quarto widget more +/// than a Run button: no `checks`, no `solution`, and no `hints`. +/// +/// Pure (§2.1): returns the stderr message for the CLI shell to print, or +/// `None` when any aid is present. Authors mistake such a bare widget for a +/// broken install, so the export names exactly what is missing. +pub fn thin_lesson_warning(lesson: &Lesson) -> Option { + let has_aid = !lesson.checks.is_empty() + || lesson.exercise.solution.is_some() + || lesson.exercise.hints.is_some(); + if has_aid { + return None; + } + Some( + "warning: lesson has no checks, solution, or hints; the exported \ + exercise will offer only Run and LLM feedback" + .to_string(), + ) +} + /// Render the YAML front matter that makes an exported lesson a standalone page: /// title, the blendtutor filter, and — for R only — `coi: true` with a note /// that COI does not work in book projects (ADR-0015, ADR-0019). @@ -486,4 +506,35 @@ exercise: ); assert!(page.contains("\n::: {.blendtutor-key}\n")); } + + #[test] + fn thin_lesson_warning_names_every_missing_aid() { + let lesson = Lesson::parse( + "lesson_name: \"Thin\"\nlanguage: R\nexercise:\n prompt: \"p\"\n llm_evaluation_prompt: \"{student_code}\"\n", + ) + .unwrap(); + let warning = thin_lesson_warning(&lesson).expect("a lesson with no aids warns"); + assert!(warning.starts_with("warning:"), "got: {warning}"); + for field in ["checks", "solution", "hints"] { + assert!(warning.contains(field), "missing `{field}` in: {warning}"); + } + } + + #[test] + fn thin_lesson_warning_is_silent_when_any_aid_is_present() { + for aid in ["checks:\n - \"stopifnot(TRUE)\"\n", ""] { + let extra_exercise = if aid.is_empty() { + " hints: |\n - Try it.\n" + } else { + "" + }; + let yaml = format!( + "lesson_name: \"Aided\"\nlanguage: R\n{aid}exercise:\n prompt: \"p\"\n{extra_exercise} llm_evaluation_prompt: \"{{student_code}}\"\n" + ); + let lesson = Lesson::parse(&yaml).unwrap(); + assert_eq!(thin_lesson_warning(&lesson), None, "yaml:\n{yaml}"); + } + let solved = Lesson::parse(VALID_YAML).unwrap(); + assert_eq!(thin_lesson_warning(&solved), None); + } } From cf1e5944f49804beda78bcdc9fd0c8bae46a9a49 Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 13:54:51 -0600 Subject: [PATCH 09/27] test(red): new lesson scaffolds commented optional fields solution, hints, gotchas, checks and packages were invisible to authors because the scaffold never mentioned them. Both language templates should show each one commented out, and the lesson must still validate unchanged. --- crates/cli/tests/new.rs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/cli/tests/new.rs b/crates/cli/tests/new.rs index 47c07e9..2652f97 100644 --- a/crates/cli/tests/new.rs +++ b/crates/cli/tests/new.rs @@ -245,3 +245,39 @@ fn new_lesson_refuses_a_duplicate_id_without_clobbering() { "a refused duplicate must not append a manifest entry" ); } + +#[test] +fn new_lesson_scaffolds_commented_optional_fields_for_both_languages() { + // Authors only discover solution/hints/gotchas/checks/packages if the + // scaffold shows them; they arrive commented out so the lesson validates + // unchanged and each field is one uncomment away. + let course = fresh_init_course(); + for (lang, id) in [("r", "optional_r"), ("python", "optional_py")] { + Command::cargo_bin("blendtutor") + .unwrap() + .current_dir(course.path()) + .args(["new", "lesson", "--lang", lang, id]) + .assert() + .success(); + let rel = format!("lessons/{id}.yaml"); + let yaml = std::fs::read_to_string(course.path().join(&rel)).unwrap(); + for key in [ + "# solution:", + "# hints:", + "# gotchas:", + "# checks:", + "# packages:", + ] { + assert!( + yaml.lines().any(|line| line.trim_start().starts_with(key)), + "{lang} scaffold should show a commented `{key}` field, got:\n{yaml}" + ); + } + Command::cargo_bin("blendtutor") + .unwrap() + .current_dir(course.path()) + .args(["validate", &rel]) + .assert() + .success(); + } +} From 4f00e046584c75d14be37f9b217a358f723b1c6b Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 13:55:30 -0600 Subject: [PATCH 10/27] feat: new lesson templates show commented optional fields Both the R and Python templates now list solution, hints and gotchas inside exercise, and checks and packages at lesson level, all commented out with working examples. A unit test uncomments them and parses the result, so the examples stay valid. --- crates/core/src/scaffold.rs | 62 ++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/crates/core/src/scaffold.rs b/crates/core/src/scaffold.rs index 861e64e..6de0271 100644 --- a/crates/core/src/scaffold.rs +++ b/crates/core/src/scaffold.rs @@ -176,7 +176,9 @@ fn is_empty_target(dir: &Path) -> Result { const LESSONS_DIR: &str = "lessons"; /// The exercise body of an R starter lesson: a valid `exercise` block whose -/// `llm_evaluation_prompt` carries the required `{student_code}` placeholder. +/// `llm_evaluation_prompt` carries the required `{student_code}` placeholder, +/// followed by commented-out optional fields (solution, hints, gotchas, checks, +/// packages) so authors discover them without the lesson changing meaning. const R_EXERCISE: &str = r#"exercise: type: "function_writing" prompt: | @@ -189,6 +191,13 @@ const R_EXERCISE: &str = r#"exercise: success_criteria: | - Prints exactly the word "hello" - Uses cat() + # Optional learner aids — uncomment to use. hints and gotchas are bullet lists. + # solution: | + # cat("hello\n") + # hints: | + # - cat() prints its arguments without quotes or an index. + # gotchas: | + # - print("hello") adds [1] and quotes; use cat() here. llm_evaluation_prompt: | You are grading a beginner R exercise: print the word "hello" with cat(). @@ -198,6 +207,11 @@ const R_EXERCISE: &str = r#"exercise: Decide whether it prints "hello" and call respond_with_feedback with your assessment. Set is_correct true when the requirement is met, and give two or three sentences of encouraging feedback. +# Optional lesson-level fields — uncomment to use. +# checks: +# - "stopifnot(is.function(cat))" +# packages: +# - dplyr "#; /// The exercise body of a Python starter lesson: the `print()` twin of @@ -214,6 +228,13 @@ const PYTHON_EXERCISE: &str = r#"exercise: success_criteria: | - Prints exactly the word "hello" - Uses print() + # Optional learner aids — uncomment to use. hints and gotchas are bullet lists. + # solution: | + # print("hello") + # hints: | + # - print() adds a trailing newline for you. + # gotchas: | + # - Quote the word: print(hello) looks up a variable named hello. llm_evaluation_prompt: | You are grading a beginner Python exercise: print the word "hello" with print(). @@ -223,6 +244,11 @@ const PYTHON_EXERCISE: &str = r#"exercise: Decide whether it prints "hello" and call respond_with_feedback with your assessment. Set is_correct true when the requirement is met, and give two or three sentences of encouraging feedback. +# Optional lesson-level fields — uncomment to use. +# checks: +# - "assert callable(print)" +# packages: +# - pandas "#; /// Render a starter lesson for `language` under the slug `id`. @@ -665,6 +691,40 @@ mod tests { assert!(std::error::Error::source(&write).is_some()); } + /// Uncomment every optional field the template shows: drop the `# ` from + /// commented lines except the file header and the `# Optional ...` labels. + fn uncomment_optional_fields(yaml: &str) -> String { + yaml.lines() + .map(|line| { + let trimmed = line.trim_start(); + let indent = &line[..line.len() - trimmed.len()]; + let is_label = trimmed.starts_with("# Optional") + || trimmed.starts_with("# A lesson scaffolded") + || trimmed.starts_with("# changes with"); + match trimmed.strip_prefix("# ") { + Some(rest) if !is_label => format!("{indent}{rest}"), + _ => line.to_string(), + } + }) + .collect::>() + .join("\n") + } + + #[test] + fn lesson_template_optional_fields_parse_once_uncommented() { + for language in [Language::R, Language::Python] { + let yaml = uncomment_optional_fields(&lesson_template(language.clone(), "aided")); + let lesson = Lesson::parse(&yaml).unwrap_or_else(|e| { + panic!("uncommented {language:?} template must parse: {e}\n{yaml}") + }); + assert!(lesson.exercise.solution.is_some(), "{language:?} solution"); + assert!(lesson.exercise.hints.is_some(), "{language:?} hints"); + assert!(lesson.exercise.gotchas.is_some(), "{language:?} gotchas"); + assert_eq!(lesson.checks.len(), 1, "{language:?} checks"); + assert_eq!(lesson.packages.len(), 1, "{language:?} packages"); + } + } + #[test] fn lesson_template_produces_a_valid_python_lesson() { // The pure selector (§2.1) returns a Python lesson the production parser From 6da8ecaaca8b612204b81f021b65f39b0a4678b3 Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 14:03:37 -0600 Subject: [PATCH 11/27] test(red): success criteria reach the feedback prompt and the filter payload build_prompt must place a neutralized "Success criteria:" section between the task and the code fence when the lesson has criteria, and stay unchanged without them. The Quarto filter must parse a ::: {.success-criteria} div into a 10th payload key, and the browser buildPrompt must mirror the Rust layout. --- crates/core/tests/llm.rs | 43 +++++++++++++++++++++++++++ quarto-fixture/filter.qmd | 4 +++ scripts/tests/test_quarto_feedback.py | 12 ++++++++ scripts/tests/test_quarto_filter.sh | 14 ++++----- scripts/tests/verify_filter_output.py | 29 ++++++++++++++---- 5 files changed, 90 insertions(+), 12 deletions(-) diff --git a/crates/core/tests/llm.rs b/crates/core/tests/llm.rs index 5acbcdb..c5c8527 100644 --- a/crates/core/tests/llm.rs +++ b/crates/core/tests/llm.rs @@ -443,3 +443,46 @@ async fn anthropic_missing_key_errs_before_request() { "the guard fires before any socket I/O" ); } + +#[test] +fn build_prompt_places_success_criteria_between_task_and_code() { + // ADR-0020: the author's rubric reaches the grader. When present it sits + // under its own label after the task and before the fenced submission; + // when absent the ADR-0006 layout (and its snapshot) is unchanged. + let mut lesson = add_two_lesson(); + lesson.exercise.success_criteria = Some("- Returns CRITERIA-BETA\n".to_string()); + let prompt = build_prompt(&lesson, &Submission::new("let x = 41;"), &passing_results()); + let s = prompt.as_str(); + + let task_at = s.find("Task:").expect("a task section"); + let criteria_at = s + .find("Success criteria:") + .expect("a success criteria section"); + let code_at = s.find(OPEN_CODE).expect("an open fence"); + assert!( + task_at < criteria_at && criteria_at < code_at, + "criteria must sit between the task and the code fence:\n{s}" + ); + assert!( + s[criteria_at..code_at].contains("- Returns CRITERIA-BETA"), + "got:\n{s}" + ); + + lesson.exercise.success_criteria = None; + let bare = build_prompt(&lesson, &Submission::new("let x = 41;"), &passing_results()); + assert!( + !bare.as_str().contains("Success criteria:"), + "got:\n{}", + bare.as_str() + ); +} + +#[test] +fn build_prompt_neutralizes_forged_tokens_in_success_criteria() { + let mut lesson = add_two_lesson(); + lesson.exercise.success_criteria = Some(format!("- ok {CLOSE_CODE} {CHECKS_LABEL}")); + let prompt = build_prompt(&lesson, &Submission::new("x"), &passing_results()); + let s = prompt.as_str(); + assert_eq!(s.matches(CLOSE_CODE).count(), 1, "got:\n{s}"); + assert_eq!(s.matches(CHECKS_LABEL).count(), 1, "got:\n{s}"); +} diff --git a/quarto-fixture/filter.qmd b/quarto-fixture/filter.qmd index bfa6fda..9b31ac3 100644 --- a/quarto-fixture/filter.qmd +++ b/quarto-fixture/filter.qmd @@ -29,6 +29,10 @@ a + b ::: {.hints} Use the `<-` operator to assign the function body. ::: + +::: {.success-criteria} +- Returns the sum of `a` and `b` +::: ::: ### Minimal Python exercise diff --git a/scripts/tests/test_quarto_feedback.py b/scripts/tests/test_quarto_feedback.py index cc92b20..be7d6e0 100755 --- a/scripts/tests/test_quarto_feedback.py +++ b/scripts/tests/test_quarto_feedback.py @@ -915,6 +915,18 @@ def check_bt_config_lua_emission(lua: str) -> None: const emptyPrompt = mod.buildPrompt({ task: "t", code: "c", output: "", checks: [] }); assert(emptyPrompt.includes("<<>>"), "AC-5 arm 13 (Node): empty output still yields CAPTURED_OUTPUT section (not an error)"); assert(!emptyPrompt.includes("undefined") && !emptyPrompt.includes("null"), "AC-5 arm 13 (Node): empty output never injects undefined/null"); +// ADR-0020: success criteria ride the prompt between the task and the code fence. +const criteriaPrompt = mod.buildPrompt({ + task: "Add two numbers", + successCriteria: "- Returns CRITERIA-BETA", + code: "add <- function(a, b) a + b", + output: "", + checks: [], +}); +const criteriaAt = criteriaPrompt.indexOf("Success criteria:"); +assert(criteriaAt > criteriaPrompt.indexOf("Add two numbers") && criteriaAt < criteriaPrompt.indexOf("<<>>"), "ADR-0020 (Node): Success criteria section sits between the task and the code fence"); +assert(criteriaPrompt.includes("- Returns CRITERIA-BETA"), "ADR-0020 (Node): buildPrompt includes the success criteria text"); +assert(!ac5Prompt.includes("Success criteria:"), "ADR-0020 (Node): no Success criteria section when criteria are absent"); assert(mod.PROVIDERS.fireworks.fallbackModel === "accounts/fireworks/models/deepseek-v4-flash-0731", "AC-5 arm 3 (Node): PROVIDERS.fireworks.fallbackModel pinned to -0731"); // --- AC-5 DOM→prompt wiring (fetch-spy behavioral suite) ------------------- diff --git a/scripts/tests/test_quarto_filter.sh b/scripts/tests/test_quarto_filter.sh index 1e19b54..8e279a2 100755 --- a/scripts/tests/test_quarto_filter.sh +++ b/scripts/tests/test_quarto_filter.sh @@ -3,11 +3,11 @@ # # Verifies the compound predicate from AC-2: # 1. HTML render produces >=3 div.bt-exercise with script[type=application/json] -# 2. JSON has all 9 SiteLesson keys, llm_evaluation_prompt ABSENT +# 2. JSON has all 10 SiteLesson keys, llm_evaluation_prompt ABSENT # 3. Full exercise: prompt , code_template <-, checks len 2, # solution "a + b", hints <-, gotchas null, packages [] -# 4. Minimal Python: packages parsed, all 9 keys present -# 5. Empty exercise: all 9 keys present (null/[] for absent) +# 4. Minimal Python: packages parsed, all 10 keys present +# 5. Empty exercise: all 10 keys present (null/[] for absent) # 6. IDs distinct, titles non-empty # 7. Non-HTML format: no bt-exercise + warning # 8. Invalid language: warning + skip (no bt-exercise) @@ -123,17 +123,17 @@ fi if [ ! -f "$HTML_FILE" ]; then ko "HTML output exists — not found: $HTML_FILE" ko ">=3 bt-exercise widgets — HTML missing" - ko "all 9 keys present — HTML missing" + ko "all 10 keys present — HTML missing" ko "full exercise values — HTML missing" ko "IDs distinct — HTML missing" else # Run Python JSON assertions (covers assertions 1-6) python3 scripts/tests/verify_filter_output.py "$HTML_FILE" 2>&1 && PY_RC=0 || PY_RC=$? if [ "$PY_RC" -eq 0 ]; then - ok ">=4 bt-exercise widgets with all 9 keys" + ok ">=4 bt-exercise widgets with all 10 keys" ok "full exercise values correct (prompt , code_template <-, checks len 2, solution 'a + b', hints <-, gotchas null, packages [])" - ok "minimal Python: packages parsed, all 9 keys present" - ok "empty exercise: all 9 keys present (null/[] for absent)" + ok "minimal Python: packages parsed, all 10 keys present" + ok "empty exercise: all 10 keys present (null/[] for absent)" ok "IDs distinct, titles non-empty" ok "llm_evaluation_prompt ABSENT" else diff --git a/scripts/tests/verify_filter_output.py b/scripts/tests/verify_filter_output.py index c6b75d0..4a1e1be 100755 --- a/scripts/tests/verify_filter_output.py +++ b/scripts/tests/verify_filter_output.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 -"""Verify filter output: assert 9-key SiteLesson JSON contract + data-language. +"""Verify filter output: assert 10-key SiteLesson JSON contract + data-language. Reads an HTML file, extracts all bt-exercise widget JSON payloads and their data-language attributes, and asserts: - - the 9-key SiteLesson contract from AC-2's executable spec + - the 10-key SiteLesson contract from AC-2's executable spec - every bt-exercise div carries exactly one data-language="r|python" (all-or-none: attribute count must equal widget count) - per-index language pairing matches quarto-fixture/filter.qmd order @@ -20,7 +20,8 @@ from typing import Any -# The 9 SiteLesson keys that MUST be present in every widget JSON. +# The 10 SiteLesson keys that MUST be present in every widget JSON +# (ADR-0020 added success_criteria). REQUIRED_KEYS: set[str] = { "id", "title", @@ -31,6 +32,7 @@ "solution", "hints", "gotchas", + "success_criteria", } # Keys that MUST NOT appear — llm_evaluation_prompt is server/CLI only @@ -137,6 +139,13 @@ def assert_full_exercise(data: dict[str, Any], errors: list[str]) -> None: f"Full exercise: packages should be [], got: {data['packages']!r}" ) + # success_criteria from the nested .success-criteria div (ADR-0020) + criteria = data["success_criteria"] + if criteria is None or "sum of" not in criteria: + errors.append( + f"Full exercise: success_criteria missing 'sum of' — got: {criteria!r}" + ) + def assert_minimal_python(data: dict[str, Any], errors: list[str]) -> None: """Assert conditions for the minimal Python exercise (index 1). @@ -169,6 +178,11 @@ def assert_minimal_python(data: dict[str, Any], errors: list[str]) -> None: f"Minimal Python: gotchas should be null, got: {data['gotchas']!r}" ) + if data["success_criteria"] is not None: + errors.append( + f"Minimal Python: success_criteria should be null, got: {data['success_criteria']!r}" + ) + def assert_xss_gotchas_exercise(data: dict[str, Any], errors: list[str]) -> None: """Assert conditions for the XSS + gotchas exercise (index 3). @@ -228,6 +242,11 @@ def assert_empty_exercise(data: dict[str, Any], errors: list[str]) -> None: f"Empty exercise: gotchas should be null, got: {data['gotchas']!r}" ) + if data["success_criteria"] is not None: + errors.append( + f"Empty exercise: success_criteria should be null, got: {data['success_criteria']!r}" + ) + def assert_static_fallback( html: str, widgets: list[dict[str, Any]], errors: list[str] @@ -356,7 +375,7 @@ def main() -> int: # BEFORE the payload script (fix-demo-visible-exercises Part 1). assert_static_fallback(html, widgets, errors) - # For each widget: 9 keys present, no forbidden keys, title non-empty + # For each widget: 10 keys present, no forbidden keys, title non-empty for i, data in enumerate(widgets): keys = set(data.keys()) missing = REQUIRED_KEYS - keys @@ -367,7 +386,7 @@ def main() -> int: errors.append(f"Exercise {i}: missing keys: {sorted(missing)}") if extra: errors.append( - f"Exercise {i}: extra keys (not in 9-key contract): {sorted(extra)}" + f"Exercise {i}: extra keys (not in 10-key contract): {sorted(extra)}" ) if forbidden: errors.append(f"Exercise {i}: FORBIDDEN key present: {sorted(forbidden)}") From 9342cf0608d3abdd7533886ca40398790ab2b391 Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 14:08:31 -0600 Subject: [PATCH 12/27] docs: ADR-0020 success criteria reach the LLM feedback prompt --- ...020-success-criteria-in-feedback-prompt.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/adr/0020-success-criteria-in-feedback-prompt.md diff --git a/docs/adr/0020-success-criteria-in-feedback-prompt.md b/docs/adr/0020-success-criteria-in-feedback-prompt.md new file mode 100644 index 0000000..41f16a6 --- /dev/null +++ b/docs/adr/0020-success-criteria-in-feedback-prompt.md @@ -0,0 +1,52 @@ +# ADR-0020: Success criteria reach the LLM feedback prompt + +- Status: Accepted +- Date: 2026-09-13 +- Amends: ADR-0006 (prompt layout), ADR-0008 (SiteLesson contract) + +## Context + +ADR-0006 replaced each lesson's `llm_evaluation_prompt` with one fixed, +injection-hardened prompt: task, fenced submission, captured output, check +results. The lesson schema still accepts `exercise.success_criteria`, but no +consumer reads it — not `build_prompt`, not the `SiteLesson` JSON, not the +Quarto filter. So a lesson graded by the LLM alone (for example "write +pseudocode as comments", which has no executable checks) is judged against the +prompt text only, and the author's rubric is silently discarded in the CLI, +the static site, and Quarto pages. + +## Options + +1. **Restore `llm_evaluation_prompt` templates.** Gives authors full control, + but reopens the prompt-injection surface ADR-0006 closed and ships an + author-only field to the browser (ADR-0008 §3.2 leak). +2. **Fold criteria into `exercise.prompt` by convention.** No code change, but + learners then see grading text as the task, and existing lessons stay + unrubric'd. +3. **Add an optional, neutralized "Success criteria" section to the fixed + prompt**, carried through every learner-side contract. + +## Decision + +Option 3. + +- **Prompt layout (amends ADR-0006).** When `success_criteria` is present, + `build_prompt` (Rust) and `buildPrompt` (both JS copies) insert + `Success criteria:` and the neutralized text immediately after the task + section. When absent, the prompt is byte-identical to the ADR-0006 layout, + so existing snapshots and lessons are unaffected. +- **Contracts (amends ADR-0008).** `SiteLesson` gains `success_criteria` + (always serialized, `null` when absent, mirroring `solution`/`hints`). The + Quarto payload grows from 9 to 10 keys; authors write criteria in a nested + `::: {.success-criteria}` div, which `export-quarto` emits from the lesson. +- **Not shipped:** `llm_evaluation_prompt` remains author-only. + +## Consequences + +- Criteria are visible in page source, like `solution` already is. They are + grading guidance, not secrets. +- Three prompt builders must stay in lockstep (Rust, `assets/shared/feedback.js`, + `_extensions/.../exercise-feedback.js`); tests assert the same section label + and placement in each. +- `scripts/tests/verify_filter_output.py` and the demo-book vendored extension + copy update with the 10-key contract. From 5ee20d75d200a57dc5f0e930ab3ae297a56e7663 Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 14:09:55 -0600 Subject: [PATCH 13/27] fix: export_lesson_to_qmd docs no longer link a private item The intra-doc link to the private front_matter fn broke RUSTDOCFLAGS="-D warnings" cargo doc (rustdoc::private_intra_doc_links), which scripts/check-docs.sh and the docs workflow enforce. --- crates/core/src/quarto_export.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/core/src/quarto_export.rs b/crates/core/src/quarto_export.rs index 0f38855..ec070f1 100644 --- a/crates/core/src/quarto_export.rs +++ b/crates/core/src/quarto_export.rs @@ -95,7 +95,8 @@ const MIN_FENCE_LEN: usize = 3; /// present, so no empty blocks appear for absent fields (§1.1). The /// author-only `llm_evaluation_prompt` is excluded (ADR-0006). /// -/// With [`ExportShape::Document`] the div is preceded by [`front_matter`]. +/// With [`ExportShape::Document`] the div is preceded by YAML front matter +/// (title, the blendtutor filter, and `coi: true` for R). /// /// # Arguments /// * `lesson` — A valid, parsed lesson (constructed via [`Lesson::parse`]). From 511f67b838a2262bb3a2b769d04ac0446a333526 Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 14:13:51 -0600 Subject: [PATCH 14/27] feat: success criteria reach the LLM feedback prompt in CLI, site, and Quarto build_prompt and both browser buildPrompt copies add a neutralized "Success criteria:" section between the task and the fenced submission when a lesson has non-blank criteria, leaving the prompt unchanged otherwise. SiteLesson and the Quarto filter payload carry success_criteria (10 keys); the filter reads it from a ::: {.success-criteria} div, which export-quarto now emits. Vendored demo-book extension copies are synced. --- .../blendtutor/assets/exercise-feedback.js | 9 ++++- _extensions/blendtutor/blendtutor.lua | 16 ++++++--- crates/core/assets/shared/feedback.js | 9 ++++- crates/core/src/llm/prompt.rs | 25 ++++++++++--- crates/core/src/quarto_export.rs | 35 ++++++++++++++++++- crates/core/src/site/mod.rs | 13 +++++++ .../blendtutor/assets/exercise-feedback.js | 9 ++++- .../mcmullarkey/blendtutor/blendtutor.lua | 16 ++++++--- 8 files changed, 116 insertions(+), 16 deletions(-) diff --git a/_extensions/blendtutor/assets/exercise-feedback.js b/_extensions/blendtutor/assets/exercise-feedback.js index 6d6f61f..f484595 100644 --- a/_extensions/blendtutor/assets/exercise-feedback.js +++ b/_extensions/blendtutor/assets/exercise-feedback.js @@ -119,14 +119,20 @@ export function neutralize(text) { // task, a single fenced copy of the submission, captured output, and the lesson's // checks — every interpolated value neutralized so the fences and labels appear // exactly once even when the submission forges them (ADR-0006 §2.1–§2.3). -export function buildPrompt({ task, code, output, checks }) { +export function buildPrompt({ task, successCriteria, code, output, checks }) { const renderedChecks = (checks ?? []).map((check) => neutralize(check)).join("\n"); + // ADR-0020: optional rubric between the task and the fence, skipped when blank + // (mirrors the Rust build_prompt). + const criteria = String(successCriteria ?? "").trim() === "" + ? [] + : ["Success criteria:", neutralize(successCriteria).replace(/\s+$/, ""), ""]; return [ "You are evaluating student code for a programming exercise.", "", "Task:", neutralize(task).replace(/\s+$/, ""), "", + ...criteria, OPEN_CODE, neutralize(code).replace(/\n+$/, ""), CLOSE_CODE, @@ -409,6 +415,7 @@ function currentSubmissionForExercise(entry) { const outputEl = entry.element.querySelector(".bt-output"); return { task: lesson ? lesson.prompt : "", + successCriteria: lesson ? lesson.success_criteria : null, code: entry.getSubmission ? entry.getSubmission() : "", output: outputEl ? outputEl.textContent : "", checks: lesson && lesson.checks ? lesson.checks : [], diff --git a/_extensions/blendtutor/blendtutor.lua b/_extensions/blendtutor/blendtutor.lua index 626716b..f0fbb83 100644 --- a/_extensions/blendtutor/blendtutor.lua +++ b/_extensions/blendtutor/blendtutor.lua @@ -1,6 +1,6 @@ --- blendtutor.lua --- -- WHAT: Pandoc filter that parses ::: {.blendtutor} divs into widget HTML --- with embedded 9-key SiteLesson JSON (ADR-0008 contract), and +-- with embedded 10-key SiteLesson JSON (ADR-0008 contract), and -- injects the auto-bootstrap module script that boots the exercise -- runtime with per-language adapters (AC-3). -- ISSUE #164 (byok-api-key AC-3) additionally: @@ -38,8 +38,9 @@ -- standalone and project modes. For distribution via `quarto add`, the -- _extension.yml contributes.filters mechanism is used instead. -- --- SiteLesson JSON contract (9 keys, ADR-0008): --- id, title, prompt, code_template, checks, packages, solution, hints, gotchas +-- SiteLesson JSON contract (10 keys, ADR-0008 amended by ADR-0020): +-- id, title, prompt, code_template, checks, packages, solution, hints, gotchas, +-- success_criteria -- llm_evaluation_prompt is NEVER emitted (server/CLI concern, §3.2 leak). -- Module-level exercise counter for auto-generated IDs (bt-exercise-). @@ -383,6 +384,7 @@ end -- - CodeBlock with .solution class → solution -- - Nested Div with .hints class → hints (rendered to markdown) -- - Nested Div with .gotchas class → gotchas (rendered to markdown) +-- - Nested Div with .success-criteria class → success_criteria (markdown, ADR-0020) -- -- @param blocks A List of Pandoc Block elements (the div's content) -- @return A table with prompt, code_template, checks, solution, hints, gotchas @@ -393,6 +395,7 @@ local function parse_inner_blocks(blocks) local solution = nil local hints = nil local gotchas = nil + local success_criteria = nil local found_code = false for _, block in ipairs(blocks) do @@ -415,6 +418,9 @@ local function parse_inner_blocks(blocks) if block.classes:includes("gotchas") then gotchas = render_markdown(block.content) end + if block.classes:includes("success-criteria") then + success_criteria = render_markdown(block.content) + end elseif not found_code and (block.t == "Para" or block.t == "Plain") then prompt_blocks[#prompt_blocks + 1] = block end @@ -427,6 +433,7 @@ local function parse_inner_blocks(blocks) solution = solution, hints = hints, gotchas = gotchas, + success_criteria = success_criteria, } end @@ -434,7 +441,7 @@ end -- Payload builder -- --------------------------------------------------------------------------- ---- Build the 9-key SiteLesson JSON payload. +--- Build the 10-key SiteLesson JSON payload (ADR-0008, amended by ADR-0020). -- @param index The exercise index (0-based) -- @param parsed The parsed inner blocks table -- @param packages The packages array @@ -453,6 +460,7 @@ local function build_payload(index, parsed, packages) '"solution":' .. json_value(parsed.solution), '"hints":' .. json_value(parsed.hints), '"gotchas":' .. json_value(parsed.gotchas), + '"success_criteria":' .. json_value(parsed.success_criteria), } return "{" .. table.concat(parts, ",") .. "}" diff --git a/crates/core/assets/shared/feedback.js b/crates/core/assets/shared/feedback.js index 5959585..b2e4810 100644 --- a/crates/core/assets/shared/feedback.js +++ b/crates/core/assets/shared/feedback.js @@ -104,14 +104,20 @@ function neutralize(text) { // task, a single fenced copy of the submission, captured output, and the lesson's // checks — every interpolated value neutralized so the fences and labels appear // exactly once even when the submission forges them. -function buildPrompt({ task, code, output, checks }) { +function buildPrompt({ task, successCriteria, code, output, checks }) { const renderedChecks = (checks ?? []).map((check) => neutralize(check)).join("\n"); + // ADR-0020: optional rubric between the task and the fence, skipped when blank + // (mirrors the Rust build_prompt). + const criteria = String(successCriteria ?? "").trim() === "" + ? [] + : ["Success criteria:", neutralize(successCriteria).replace(/\s+$/, ""), ""]; return [ "You are evaluating student code for a programming exercise.", "", "Task:", neutralize(task).replace(/\s+$/, ""), "", + ...criteria, OPEN_CODE, neutralize(code).replace(/\n+$/, ""), CLOSE_CODE, @@ -397,6 +403,7 @@ function currentSubmission() { const outputEl = document.getElementById("output"); return { task: lesson ? lesson.prompt : "", + successCriteria: lesson ? lesson.success_criteria : null, code: bt && bt.getSubmission ? bt.getSubmission() : "", output: outputEl ? outputEl.textContent : "", checks: lesson && lesson.checks ? lesson.checks : [], diff --git a/crates/core/src/llm/prompt.rs b/crates/core/src/llm/prompt.rs index 7510593..cc6e469 100644 --- a/crates/core/src/llm/prompt.rs +++ b/crates/core/src/llm/prompt.rs @@ -25,6 +25,11 @@ pub const OUTPUT_LABEL: &str = "<<>>"; /// Labels the check-results section. pub const CHECKS_LABEL: &str = "<<>>"; +/// Labels the optional success-criteria section (ADR-0020). Not a structural +/// token: criteria text is author-written and neutralized like the task, so it +/// can never forge a fence or a verdict section. +const SUCCESS_CRITERIA_LABEL: &str = "Success criteria:"; + /// What replaces any structural token found inside untrusted interpolated text, /// so an injected fence or label can never count as a real one. const NEUTRALIZED: &str = "[neutralized-delimiter]"; @@ -133,7 +138,8 @@ fn render_checks(lesson: &Lesson, outcomes: &[CheckOutcome]) -> String { /// /// Pure (§2.1): it reads only the borrowed domain values and performs no IO, env /// read, or network call, so identical inputs always render byte-identically. The -/// layout is a fixed structure — the task, a single fenced copy of the submission, +/// layout is a fixed structure — the task, the lesson's `success_criteria` when +/// present and non-blank (ADR-0020), a single fenced copy of the submission, /// a captured-output section, and a check-results section (one line per outcome, /// labeled with its `lesson.checks` entry by index) — not the lesson's /// `llm_evaluation_prompt` template (ADR-0006). `results.outcomes` is expected 1:1 @@ -144,16 +150,27 @@ fn render_checks(lesson: &Lesson, outcomes: &[CheckOutcome]) -> String { /// read as code or as a verdict. pub fn build_prompt(lesson: &Lesson, submission: &Submission, results: &ExecResults) -> Prompt { let task = neutralize(&lesson.exercise.prompt); + let criteria = lesson + .exercise + .success_criteria + .as_deref() + .filter(|text| !text.trim().is_empty()) + .map(neutralize); let code = neutralize(&submission.code); let output = neutralize(&results.output.stdout); let checks = render_checks(lesson, &results.outcomes); - let rendered = [ + let mut lines = vec![ "You are evaluating student code for a programming exercise.", "", "Task:", task.trim_end(), "", + ]; + if let Some(criteria) = &criteria { + lines.extend([SUCCESS_CRITERIA_LABEL, criteria.trim_end(), ""]); + } + lines.extend([ OPEN_CODE, code.trim_end_matches('\n'), CLOSE_CODE, @@ -163,8 +180,8 @@ pub fn build_prompt(lesson: &Lesson, submission: &Submission, results: &ExecResu "", CHECKS_LABEL, &checks, - ] - .join("\n"); + ]); + let rendered = lines.join("\n"); Prompt(rendered) } diff --git a/crates/core/src/quarto_export.rs b/crates/core/src/quarto_export.rs index ec070f1..2162f17 100644 --- a/crates/core/src/quarto_export.rs +++ b/crates/core/src/quarto_export.rs @@ -16,6 +16,7 @@ //! | `exercise.hints` | `::: {.hints}` div (if `Some`) | //! | `lesson.language` | `language=""` attribute | //! | `exercise.gotchas` | `::: {.gotchas}` div (if `Some`) | +//! | `exercise.success_criteria` | `::: {.success-criteria}` div (if `Some`, ADR-0020) | //! | `lesson.packages` | `packages="a,b"` attribute (if non-empty) | //! | `exercise.llm_evaluation_prompt` | EXCLUDED (author-only, ADR-0006) | @@ -90,7 +91,8 @@ const MIN_FENCE_LEN: usize = 3; /// /// The output is a self-contained block starting with /// `::: {.blendtutor language=""}` and closing with `:::`. Each -/// optional section (code template, checks, solution, hints, gotchas) and the +/// optional section (code template, checks, solution, hints, gotchas, success +/// criteria) and the /// `packages` attribute are emitted only when the corresponding field is /// present, so no empty blocks appear for absent fields (§1.1). The /// author-only `llm_evaluation_prompt` is excluded (ADR-0006). @@ -179,6 +181,16 @@ pub fn export_lesson_to_qmd(lesson: &Lesson, shape: ExportShape) -> String { out.push_str(":::\n"); } + // Success criteria as a fenced div (if present) — the filter carries them + // into the feedback prompt (ADR-0020). + if let Some(ref criteria) = lesson.exercise.success_criteria { + out.push('\n'); + out.push_str("::: {.success-criteria}\n"); + out.push_str(criteria.trim_end()); + out.push('\n'); + out.push_str(":::\n"); + } + // Closing div. out.push_str(":::\n"); @@ -538,4 +550,25 @@ exercise: let solved = Lesson::parse(VALID_YAML).unwrap(); assert_eq!(thin_lesson_warning(&solved), None); } + + #[test] + fn export_renders_success_criteria_as_div() { + let yaml = r#" +lesson_name: "Rubric" +language: R +exercise: + prompt: "Write pseudocode." + success_criteria: | + - Uses only comments + llm_evaluation_prompt: "Grade this: {student_code}" +"#; + let lesson = Lesson::parse(yaml).unwrap(); + let qmd = export_lesson_to_qmd(&lesson, ExportShape::Snippet); + assert!( + qmd.contains("::: {.success-criteria}\n- Uses only comments\n:::\n"), + "success criteria should render as a closed div, got:\n{qmd}" + ); + let bare = Lesson::parse(VALID_YAML).unwrap(); + assert!(!export_lesson_to_qmd(&bare, ExportShape::Snippet).contains("success-criteria")); + } } diff --git a/crates/core/src/site/mod.rs b/crates/core/src/site/mod.rs index 5923d6f..16809df 100644 --- a/crates/core/src/site/mod.rs +++ b/crates/core/src/site/mod.rs @@ -125,6 +125,10 @@ pub struct SiteLesson { /// shape stays stable — mirroring the `solution: null` and `hints: null` /// precedents from ADR-0008. pub gotchas: Option, + /// Optional author rubric, added to the in-browser LLM feedback prompt + /// between the task and the submission (ADR-0020). Always serialized (null + /// when absent) like `solution`, `hints`, and `gotchas`. + pub success_criteria: Option, } impl SiteLesson { @@ -144,6 +148,7 @@ impl SiteLesson { solution: lesson.exercise.solution.clone(), hints: lesson.exercise.hints.clone(), gotchas: lesson.exercise.gotchas.clone(), + success_criteria: lesson.exercise.success_criteria.clone(), } } } @@ -787,6 +792,10 @@ mod tests { site_lesson.gotchas, lesson.exercise.gotchas, "gotchas ride the contract verbatim from exercise.gotchas" ); + assert_eq!( + site_lesson.success_criteria, lesson.exercise.success_criteria, + "success_criteria ride the contract verbatim (ADR-0020)" + ); } #[test] @@ -979,6 +988,10 @@ exercise: lesson.get("gotchas").is_some() && lesson["gotchas"].is_null(), "a missing gotchas serializes as null, never dropped: {lesson}" ); + assert!( + lesson.get("success_criteria").is_some(), + "success_criteria is always serialized, never dropped: {lesson}" + ); } #[test] diff --git a/demo-book/_extensions/mcmullarkey/blendtutor/assets/exercise-feedback.js b/demo-book/_extensions/mcmullarkey/blendtutor/assets/exercise-feedback.js index 6d6f61f..f484595 100644 --- a/demo-book/_extensions/mcmullarkey/blendtutor/assets/exercise-feedback.js +++ b/demo-book/_extensions/mcmullarkey/blendtutor/assets/exercise-feedback.js @@ -119,14 +119,20 @@ export function neutralize(text) { // task, a single fenced copy of the submission, captured output, and the lesson's // checks — every interpolated value neutralized so the fences and labels appear // exactly once even when the submission forges them (ADR-0006 §2.1–§2.3). -export function buildPrompt({ task, code, output, checks }) { +export function buildPrompt({ task, successCriteria, code, output, checks }) { const renderedChecks = (checks ?? []).map((check) => neutralize(check)).join("\n"); + // ADR-0020: optional rubric between the task and the fence, skipped when blank + // (mirrors the Rust build_prompt). + const criteria = String(successCriteria ?? "").trim() === "" + ? [] + : ["Success criteria:", neutralize(successCriteria).replace(/\s+$/, ""), ""]; return [ "You are evaluating student code for a programming exercise.", "", "Task:", neutralize(task).replace(/\s+$/, ""), "", + ...criteria, OPEN_CODE, neutralize(code).replace(/\n+$/, ""), CLOSE_CODE, @@ -409,6 +415,7 @@ function currentSubmissionForExercise(entry) { const outputEl = entry.element.querySelector(".bt-output"); return { task: lesson ? lesson.prompt : "", + successCriteria: lesson ? lesson.success_criteria : null, code: entry.getSubmission ? entry.getSubmission() : "", output: outputEl ? outputEl.textContent : "", checks: lesson && lesson.checks ? lesson.checks : [], diff --git a/demo-book/_extensions/mcmullarkey/blendtutor/blendtutor.lua b/demo-book/_extensions/mcmullarkey/blendtutor/blendtutor.lua index 626716b..f0fbb83 100644 --- a/demo-book/_extensions/mcmullarkey/blendtutor/blendtutor.lua +++ b/demo-book/_extensions/mcmullarkey/blendtutor/blendtutor.lua @@ -1,6 +1,6 @@ --- blendtutor.lua --- -- WHAT: Pandoc filter that parses ::: {.blendtutor} divs into widget HTML --- with embedded 9-key SiteLesson JSON (ADR-0008 contract), and +-- with embedded 10-key SiteLesson JSON (ADR-0008 contract), and -- injects the auto-bootstrap module script that boots the exercise -- runtime with per-language adapters (AC-3). -- ISSUE #164 (byok-api-key AC-3) additionally: @@ -38,8 +38,9 @@ -- standalone and project modes. For distribution via `quarto add`, the -- _extension.yml contributes.filters mechanism is used instead. -- --- SiteLesson JSON contract (9 keys, ADR-0008): --- id, title, prompt, code_template, checks, packages, solution, hints, gotchas +-- SiteLesson JSON contract (10 keys, ADR-0008 amended by ADR-0020): +-- id, title, prompt, code_template, checks, packages, solution, hints, gotchas, +-- success_criteria -- llm_evaluation_prompt is NEVER emitted (server/CLI concern, §3.2 leak). -- Module-level exercise counter for auto-generated IDs (bt-exercise-). @@ -383,6 +384,7 @@ end -- - CodeBlock with .solution class → solution -- - Nested Div with .hints class → hints (rendered to markdown) -- - Nested Div with .gotchas class → gotchas (rendered to markdown) +-- - Nested Div with .success-criteria class → success_criteria (markdown, ADR-0020) -- -- @param blocks A List of Pandoc Block elements (the div's content) -- @return A table with prompt, code_template, checks, solution, hints, gotchas @@ -393,6 +395,7 @@ local function parse_inner_blocks(blocks) local solution = nil local hints = nil local gotchas = nil + local success_criteria = nil local found_code = false for _, block in ipairs(blocks) do @@ -415,6 +418,9 @@ local function parse_inner_blocks(blocks) if block.classes:includes("gotchas") then gotchas = render_markdown(block.content) end + if block.classes:includes("success-criteria") then + success_criteria = render_markdown(block.content) + end elseif not found_code and (block.t == "Para" or block.t == "Plain") then prompt_blocks[#prompt_blocks + 1] = block end @@ -427,6 +433,7 @@ local function parse_inner_blocks(blocks) solution = solution, hints = hints, gotchas = gotchas, + success_criteria = success_criteria, } end @@ -434,7 +441,7 @@ end -- Payload builder -- --------------------------------------------------------------------------- ---- Build the 9-key SiteLesson JSON payload. +--- Build the 10-key SiteLesson JSON payload (ADR-0008, amended by ADR-0020). -- @param index The exercise index (0-based) -- @param parsed The parsed inner blocks table -- @param packages The packages array @@ -453,6 +460,7 @@ local function build_payload(index, parsed, packages) '"solution":' .. json_value(parsed.solution), '"hints":' .. json_value(parsed.hints), '"gotchas":' .. json_value(parsed.gotchas), + '"success_criteria":' .. json_value(parsed.success_criteria), } return "{" .. table.concat(parts, ",") .. "}" From b57799b079b2c48c4ff0f3498492f8d8a06fc789 Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 14:15:48 -0600 Subject: [PATCH 15/27] fix(review): parse_inner_blocks @return doc lists success_criteria --- _extensions/blendtutor/blendtutor.lua | 3 ++- demo-book/_extensions/mcmullarkey/blendtutor/blendtutor.lua | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/_extensions/blendtutor/blendtutor.lua b/_extensions/blendtutor/blendtutor.lua index f0fbb83..654e9d5 100644 --- a/_extensions/blendtutor/blendtutor.lua +++ b/_extensions/blendtutor/blendtutor.lua @@ -387,7 +387,8 @@ end -- - Nested Div with .success-criteria class → success_criteria (markdown, ADR-0020) -- -- @param blocks A List of Pandoc Block elements (the div's content) --- @return A table with prompt, code_template, checks, solution, hints, gotchas +-- @return A table with prompt, code_template, checks, solution, hints, gotchas, +-- success_criteria local function parse_inner_blocks(blocks) local prompt_blocks = {} local code_template = nil diff --git a/demo-book/_extensions/mcmullarkey/blendtutor/blendtutor.lua b/demo-book/_extensions/mcmullarkey/blendtutor/blendtutor.lua index f0fbb83..654e9d5 100644 --- a/demo-book/_extensions/mcmullarkey/blendtutor/blendtutor.lua +++ b/demo-book/_extensions/mcmullarkey/blendtutor/blendtutor.lua @@ -387,7 +387,8 @@ end -- - Nested Div with .success-criteria class → success_criteria (markdown, ADR-0020) -- -- @param blocks A List of Pandoc Block elements (the div's content) --- @return A table with prompt, code_template, checks, solution, hints, gotchas +-- @return A table with prompt, code_template, checks, solution, hints, gotchas, +-- success_criteria local function parse_inner_blocks(blocks) local prompt_blocks = {} local code_template = nil From 8f77576021c2e6b84e60450f5d4a0aada6d9886a Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 14:16:17 -0600 Subject: [PATCH 16/27] docs: update Quarto export, install location, and learner-aid authoring README and the book now say to run quarto add from the folder holding _quarto.yml, document export-quarto --document/--key-page and the thin-lesson warning, list the .solution/.hints/.gotchas/.success-criteria blocks and packages attribute, and note that COI (and so runnable R) does not work in type: book projects. --- README.md | 22 ++++++++++++++++++++++ docs/book/src/creating-lessons.md | 22 +++++++++++++++++++--- docs/book/src/whole-game.md | 13 ++++++++++--- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index eebf195..b316870 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,11 @@ Installs to `_extensions/mcmullarkey/blendtutor/` (version 0.1.0). Asset resolution is install-path-independent — assets deploy alongside the rendered HTML, so the extension works regardless of where `quarto add` installs it. +> **Run `quarto add` from the folder that contains `_quarto.yml`** (or the +> `.qmd`, for a standalone page). Quarto only discovers `_extensions/` next to +> the project file; an extension installed one directory up is never loaded, +> and exercises render as plain prose. + #### Quick start (zero hand-written bootstrap) A complete copy-paste document — zero hand-written bootstrap. Filter by name, @@ -94,6 +99,23 @@ add <- function(a, b) { ___ } Render, open in a browser — interactive immediately. Grade submissions with a `{.r .checks}` block (`stopifnot(add(1, 2) == 3)`); Python: same div, `language="python"`. +Inside the div, optional blocks add learner aids: a `{.r .solution}` code block, +`::: {.hints}` and `::: {.gotchas}` bullet divs, and a `::: {.success-criteria}` +div whose rubric is added to the AI feedback prompt. Preload packages with a +`packages="dplyr,purrr"` attribute on the `.blendtutor` div. + +#### From a lesson YAML + +`blendtutor export-quarto` writes the div for you, carrying every field above: + +```bash +blendtutor export-quarto lesson.yaml # snippet to paste +blendtutor export-quarto --document lesson.yaml > page.qmd # complete page +blendtutor export-quarto --key-page > api-key.qmd # API key page +``` + +It warns on stderr when a lesson has no checks, solution, or hints. + #### Auto-bootstrap opt-out The filter auto-bootstraps by default; to wire up the runtime yourself, set diff --git a/docs/book/src/creating-lessons.md b/docs/book/src/creating-lessons.md index cb5f66a..a130928 100644 --- a/docs/book/src/creating-lessons.md +++ b/docs/book/src/creating-lessons.md @@ -135,13 +135,29 @@ Emits a fully static site — `index.html`, per-lesson JSON, the in-browser runt ## Step 11 — Export a lesson to Quarto -`export-quarto` converts a single lesson YAML into a Quarto fenced-div snippet on stdout — validating first and refusing invalid lessons: +`export-quarto` converts a single lesson YAML into Quarto source on stdout — validating first and refusing invalid lessons. It has three shapes: ```bash -blendtutor export-quarto lessons/lesson_hello.yaml > my-exercises.qmd +# A fenced-div snippet to paste into an existing page +blendtutor export-quarto lessons/lesson_hello.yaml + +# A complete, renderable page (title, filter, and coi for R in the front matter) +blendtutor export-quarto --document lessons/lesson_hello.yaml > hello.qmd + +# The API key page learners use to store their Fireworks key +blendtutor export-quarto --key-page > api-key.qmd ``` -To render exercises, `quarto add mcmullarkey/blendtutor`, add the `filters: [mcmullarkey/blendtutor]` front-matter filter, then `quarto render my-exercises.qmd` — requirements and the rendered snippet are covered by the [README §Quarto Extension](https://github.com/mcmullarkey/blendtutor#quarto-extension). `export-quarto` prints a snippet for authoring, not a site; for the end-to-end Quarto deploy see [whole-game §Quarto deploy](./whole-game.md#quarto-deploy). +Every optional field the widget understands is carried over: `code_template`, `checks`, `solution`, `hints`, `gotchas`, `success_criteria` (added to the in-browser feedback prompt), and `packages` (preloaded in webR/Pyodide). A lesson with no checks, solution, or hints still exports, with a warning on stderr: its widget offers only Run and LLM feedback. + +To render: + +1. Run `quarto add mcmullarkey/blendtutor` **from the folder that contains `_quarto.yml`** (or the `.qmd`, for a standalone page). Quarto only looks for `_extensions/` there; installing one level up leaves the filter undiscoverable and exercises render as plain text. +2. Enable the filter. `--document` and `--key-page` pages declare it themselves; for a snippet, add `filters: [mcmullarkey/blendtutor]` to the page or to `_quarto.yml` — not both, since Quarto merges the lists. +3. R exercises need `coi: true` (webR requires cross-origin isolation). COI does not work in `type: book` projects, so R exercises only run on standalone pages; Python exercises run anywhere. +4. Serve over HTTP (`quarto preview`): `file://` blocks the ES modules and `localStorage` the widget needs. + +Requirements and the rendered snippet are covered by the [README §Quarto Extension](https://github.com/mcmullarkey/blendtutor#quarto-extension); for the end-to-end deploy see [whole-game §Quarto deploy](./whole-game.md#quarto-deploy). ## Complete example courses diff --git a/docs/book/src/whole-game.md b/docs/book/src/whole-game.md index 1e35fe4..8835eae 100644 --- a/docs/book/src/whole-game.md +++ b/docs/book/src/whole-game.md @@ -170,15 +170,22 @@ blendtutor export-quarto lesson_hello.yaml ````markdown ::: {.blendtutor language="r"} - + ::: ```` +For a page that renders on its own, add `--document`; for the page learners use +to store their API key, run `blendtutor export-quarto --key-page > api-key.qmd`. + To render exercises, install the blendtutor Quarto extension — see the -[README](../../../README.md) for requirements (Quarto 1.4 or newer) — then -render: +[README](../../../README.md) for requirements (Quarto 1.4 or newer) — from the +folder that contains `_quarto.yml`, then render: ```bash quarto add mcmullarkey/blendtutor quarto render ``` + +R exercises need `coi: true`, which does not work in `type: book` projects; see +[Creating Lessons §Step 11](./creating-lessons.md#step-11--export-a-lesson-to-quarto). From fafd78212d5123df5a2a743f99ee01fac7ad6a04 Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 14:23:14 -0600 Subject: [PATCH 17/27] test(red): demo docs must say R runs in book projects without COI The deployed demo book executes R exercises with crossOriginIsolated false (webR falls back to its slower non-isolated channel), so clause c5 pinned a false "R does not run in book mode" claim. c5 now requires the true statement. c12 is also red: the previous docs commit pushed README past its 150-line ceiling. --- scripts/tests/test_demo_docs.sh | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/scripts/tests/test_demo_docs.sh b/scripts/tests/test_demo_docs.sh index 9bdde3c..d1a662e 100644 --- a/scripts/tests/test_demo_docs.sh +++ b/scripts/tests/test_demo_docs.sh @@ -16,8 +16,9 @@ # demo section points runnable R at the CLI-built example sites # (issue #225 relabeled; regex unchanged) + interactive Python # c4. COI book-mode limitation survives + names `type: book` -# c5. Book explicitly does NOT run R (regex; generic COI-doesn't-function -# insufficient) +# c5. Book states R DOES run without COI, on webR's slower fallback channel +# (flipped: the deployed demo book executes R with crossOriginIsolated +# false, so the old "R does not run in book" pin enforced a false claim) # c6. Pyodide accuracy guard, whole README (pyodide needs no COI) # c7. No stale /examples/ conflation inside the demo section # c8. Extend-don't-duplicate: 'COI does not function in Quarto' == 1 AND @@ -122,11 +123,13 @@ fi # c5: Book explicitly does NOT run R (demo section) — generic # COI-doesn't-function is insufficient # --------------------------------------------------------------------------- -echo "== c5: R does not run in book ==" -if printf '%s' "$DEMO_SECTION" | grep -E 'R exercises.*(don.?t|do not|cannot|not).*(run|execute)|R exercises.*unavailable|editors mount but execution' >/dev/null; then - ok "book states R does not run / editors mount but execution unavailable" +echo "== c5: R runs in book without COI ==" +if printf '%s' "$DEMO_SECTION" | grep -E 'R exercises (also )?run in (book mode|the book)' >/dev/null \ + && printf '%s' "$DEMO_SECTION" | grep -qiE 'slower|fallback|non-isolated' \ + && ! printf '%s' "$DEMO_SECTION" | grep -E 'R exercises.*(don.?t|do not|cannot).*(run|execute)|editors mount but execution' >/dev/null; then + ok "book states R runs on webR's slower non-isolated channel" else - ko "book does NOT state R-does-not-run — generic COI-doesn't-function insufficient" + ko "book does not state that R runs on the slower non-isolated channel (or still claims R does not run)" fi # --------------------------------------------------------------------------- From 37d46ac71f3ccfe72886e5519cb4e319ed5dc9ef Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 14:25:57 -0600 Subject: [PATCH 18/27] fix: docs and export comment say R runs in book projects without COI The deployed demo book runs R exercises with crossOriginIsolated false: the COI service worker registers with an assets/ scope that cannot control book pages, and webR falls back to its slower non-isolated channel. README, the book guide, ADR-0019 and the --document front-matter comment claimed R does not run in type: book projects; they now say R runs there without isolation and that coi: true speeds up standalone pages. Also trims README back under its 150-line ceiling, which the previous docs commit exceeded. --- README.md | 54 ++++++------------- crates/cli/tests/export_quarto.rs | 2 +- crates/core/src/quarto_export.rs | 13 ++--- ...019-export-quarto-document-and-key-page.md | 6 ++- docs/book/src/creating-lessons.md | 2 +- docs/book/src/whole-game.md | 2 +- 6 files changed, 30 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index b316870..bd6faee 100644 --- a/README.md +++ b/README.md @@ -67,14 +67,9 @@ checks, solution reveal, AI hints, all static HTML. Requires **Quarto >= 1.4**: quarto add mcmullarkey/blendtutor ``` -Installs to `_extensions/mcmullarkey/blendtutor/` (version 0.1.0). Asset -resolution is install-path-independent — assets deploy alongside the rendered -HTML, so the extension works regardless of where `quarto add` installs it. - -> **Run `quarto add` from the folder that contains `_quarto.yml`** (or the -> `.qmd`, for a standalone page). Quarto only discovers `_extensions/` next to -> the project file; an extension installed one directory up is never loaded, -> and exercises render as plain prose. +Installs to `_extensions/mcmullarkey/blendtutor/` (version 0.1.0). **Run it from the +folder that contains `_quarto.yml`** (or the `.qmd`): Quarto only discovers `_extensions/` +there, so one installed a directory up never loads; assets are install-path-independent. #### Quick start (zero hand-written bootstrap) @@ -99,22 +94,10 @@ add <- function(a, b) { ___ } Render, open in a browser — interactive immediately. Grade submissions with a `{.r .checks}` block (`stopifnot(add(1, 2) == 3)`); Python: same div, `language="python"`. -Inside the div, optional blocks add learner aids: a `{.r .solution}` code block, -`::: {.hints}` and `::: {.gotchas}` bullet divs, and a `::: {.success-criteria}` -div whose rubric is added to the AI feedback prompt. Preload packages with a -`packages="dplyr,purrr"` attribute on the `.blendtutor` div. - -#### From a lesson YAML - -`blendtutor export-quarto` writes the div for you, carrying every field above: - -```bash -blendtutor export-quarto lesson.yaml # snippet to paste -blendtutor export-quarto --document lesson.yaml > page.qmd # complete page -blendtutor export-quarto --key-page > api-key.qmd # API key page -``` - -It warns on stderr when a lesson has no checks, solution, or hints. +Optional blocks inside the div add a `{.r .solution}`, `::: {.hints}` / `::: {.gotchas}` +bullets, and a `::: {.success-criteria}` rubric for AI feedback; `packages="dplyr"` on the +div preloads packages. `blendtutor export-quarto lesson.yaml` writes the div from a lesson +(`--document` for a full page, `--key-page` for the API key page). #### Auto-bootstrap opt-out @@ -125,14 +108,13 @@ auto-mounted AI feedback, set `bt-feedback: false` — see ### Cross-origin isolation (COI) -webR requires `SharedArrayBuffer` → cross-origin isolation (COOP/COEP). Opt in -with `coi: true` (page YAML header) or `coi="true"` (any div); the filter -injects the same service-worker shim. Pyodide-only pages do not need COI. +webR runs faster with `SharedArrayBuffer`, which needs cross-origin isolation +(COOP/COEP). Opt in with `coi: true` (page YAML header) or `coi="true"` (any div); +the filter injects a service-worker shim. Pyodide-only pages do not need COI. > **Book-mode limitation:** COI does not function in Quarto `type: book` -> projects — the shim re-serves the page's own scope, which cannot cover the -> book's `_output/` directory. Use a standalone document for COI-enabled -> exercises (mechanics: [ADR-0015](docs/adr/0015-opt-in-coi-cross-origin.md)). +> projects — the shim's scope cannot cover the book's `_output/` pages, so webR +> uses its slower non-isolated channel ([ADR-0015](docs/adr/0015-opt-in-coi-cross-origin.md)). ### Demo book @@ -140,14 +122,10 @@ A complete demo book with R and Python exercises lives in [`demo-book/`](demo-book/), rendered live at (rebuild locally with `cd demo-book && quarto render`). It is a Quarto `type: book` project, so -COI does not take effect in the book render (limitation above). -Python exercises are fully interactive (Pyodide needs no COI) and every page ships -a static fallback. R exercises do not run in book mode — editors mount but -execution is unavailable. For runnable R, use the CLI-built example sites -([Live example sites](#deploy-to-github-pages)) — -R exercises run interactively via webR there, under the shim's isolation. -Serve the rendered book over HTTP — `file://` blocks the ES-module bootstrap -(CORS), so editors never mount and you see static exercise content only: +COI does not take effect (limitation above). Python exercises are fully interactive +and every page ships a static fallback. R exercises run in the book too, on webR's slower +fallback channel; the CLI-built [example sites](#deploy-to-github-pages) add isolation, +R exercises run interactively via webR there. Over `file://` you get static exercise content only; serve over HTTP: ```bash cd demo-book/_output && python3 -m http.server 8000 diff --git a/crates/cli/tests/export_quarto.rs b/crates/cli/tests/export_quarto.rs index a723658..0d45af7 100644 --- a/crates/cli/tests/export_quarto.rs +++ b/crates/cli/tests/export_quarto.rs @@ -412,7 +412,7 @@ fn document_r_lesson_is_a_renderable_page_with_filter_and_coi() { ); assert!( stdout.contains("type: book"), - "R documents should warn that COI does not work in book projects, got:\n{stdout}" + "R documents should note that book projects run R without COI, got:\n{stdout}" ); let body = stdout.split("\n---\n").nth(1).unwrap_or(""); assert!( diff --git a/crates/core/src/quarto_export.rs b/crates/core/src/quarto_export.rs index 2162f17..a73c118 100644 --- a/crates/core/src/quarto_export.rs +++ b/crates/core/src/quarto_export.rs @@ -37,12 +37,13 @@ pub enum ExportShape { /// The filter reference every exported page declares. const FILTER_NAME: &str = "mcmullarkey/blendtutor"; -/// Front-matter comment for R documents: webR needs cross-origin isolation, -/// which the README documents as non-functional in book projects. +/// Front-matter comment for R documents: what `coi: true` buys webR, and that +/// book projects fall back to webR's slower channel (the COI service worker's +/// scope cannot cover book pages), so R still runs there. const R_BOOK_COI_NOTE: &str = "\ -# R exercises run in webR, which needs cross-origin isolation (coi: true). -# COI does not function in Quarto `type: book` projects; render this page as a -# standalone document for runnable R. +# coi: true lets webR use SharedArrayBuffer for faster R execution. +# In Quarto `type: book` projects the COI service worker cannot control pages, +# so R runs on webR's slower fallback channel instead. "; /// A complete API key page (ADR-0019), mirroring `demo-book/api-key.qmd`. @@ -219,7 +220,7 @@ pub fn thin_lesson_warning(lesson: &Lesson) -> Option { /// Render the YAML front matter that makes an exported lesson a standalone page: /// title, the blendtutor filter, and — for R only — `coi: true` with a note -/// that COI does not work in book projects (ADR-0015, ADR-0019). +/// that book projects run R without isolation (ADR-0015, ADR-0019). fn front_matter(lesson: &Lesson) -> String { let title = yaml_double_quoted(&lesson.lesson_name.to_string()); let coi = match lesson.language { diff --git a/docs/adr/0019-export-quarto-document-and-key-page.md b/docs/adr/0019-export-quarto-document-and-key-page.md index f08d1be..578d2f8 100644 --- a/docs/adr/0019-export-quarto-document-and-key-page.md +++ b/docs/adr/0019-export-quarto-document-and-key-page.md @@ -41,8 +41,10 @@ Option 3. - **Document front matter.** `title` (from `lesson_name`), `filters: [mcmullarkey/blendtutor]`, and `coi: true` for R lessons only (Pyodide needs no isolation, ADR-0015). R documents carry a YAML comment that - COI does not function in `type: book` projects (README limitation) so the - author learns it at export time, not after deploy. + `coi: true` gives webR its faster SharedArrayBuffer channel, and that in + `type: book` projects the COI service worker cannot control pages, so R runs + on webR's slower fallback channel. Verified against the deployed demo book: + `crossOriginIsolated` is false there, yet R exercises execute. - **Key page.** `core::quarto_export::key_page_qmd()` is a pure constant-backed function returning a complete page: front matter with the filter, the `::: {.blendtutor-key}` mount div, and the storage/HTTP-serving notes diff --git a/docs/book/src/creating-lessons.md b/docs/book/src/creating-lessons.md index a130928..1f18db7 100644 --- a/docs/book/src/creating-lessons.md +++ b/docs/book/src/creating-lessons.md @@ -154,7 +154,7 @@ To render: 1. Run `quarto add mcmullarkey/blendtutor` **from the folder that contains `_quarto.yml`** (or the `.qmd`, for a standalone page). Quarto only looks for `_extensions/` there; installing one level up leaves the filter undiscoverable and exercises render as plain text. 2. Enable the filter. `--document` and `--key-page` pages declare it themselves; for a snippet, add `filters: [mcmullarkey/blendtutor]` to the page or to `_quarto.yml` — not both, since Quarto merges the lists. -3. R exercises need `coi: true` (webR requires cross-origin isolation). COI does not work in `type: book` projects, so R exercises only run on standalone pages; Python exercises run anywhere. +3. R exercises run everywhere. On standalone pages, `coi: true` lets webR use SharedArrayBuffer for faster execution; in `type: book` projects the COI service worker cannot control pages, so webR falls back to a slower channel that still runs R. Python exercises need no COI. 4. Serve over HTTP (`quarto preview`): `file://` blocks the ES modules and `localStorage` the widget needs. Requirements and the rendered snippet are covered by the [README §Quarto Extension](https://github.com/mcmullarkey/blendtutor#quarto-extension); for the end-to-end deploy see [whole-game §Quarto deploy](./whole-game.md#quarto-deploy). diff --git a/docs/book/src/whole-game.md b/docs/book/src/whole-game.md index 8835eae..085b214 100644 --- a/docs/book/src/whole-game.md +++ b/docs/book/src/whole-game.md @@ -187,5 +187,5 @@ quarto add mcmullarkey/blendtutor quarto render ``` -R exercises need `coi: true`, which does not work in `type: book` projects; see +R exercises run in books too, on webR's slower non-isolated channel; `coi: true` speeds them up on standalone pages. See [Creating Lessons §Step 11](./creating-lessons.md#step-11--export-a-lesson-to-quarto). From cef7ae5fc93a759210ad602a23625318b3769ec9 Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 15:09:23 -0600 Subject: [PATCH 19/27] test(red): Quarto widget keeps its prompt, hides idle chrome, labels the key form, follows the page theme Renders an R exercise without checks, serves it, and drives headless Chrome: the prompt must survive the runtime mount, the status badge and output box must not render before a run, the inline key form must label its input with an fw_ placeholder and a storage note, and a quarto-theme.css keyed on body.quarto-light/quarto-dark must mirror the shared token palettes. --- scripts/tests/test_quarto_display.py | 242 +++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 scripts/tests/test_quarto_display.py diff --git a/scripts/tests/test_quarto_display.py b/scripts/tests/test_quarto_display.py new file mode 100644 index 0000000..4fd9f23 --- /dev/null +++ b/scripts/tests/test_quarto_display.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Executable spec: Quarto widget display polish (ADR-0021). + +Renders an embedded R fixture (no checks, like an LLM-graded pseudocode +lesson) in a temp project with the in-repo extension installed by name, +serves it over HTTP, drives headless Chrome through rodney, and asserts: + + D1 prompt -- after the runtime mounts the editor, the exercise prompt + is still visible (the static fallback held it). + D2 idle chrome -- before anything runs, the status badge and the output box + are not rendered. + D3 key form -- Get feedback with no stored key shows an input labeled + "Fireworks API key" with an fw_ placeholder and a + "stored only in this browser" explanation. + D4 theme -- the extension ships quarto-theme.css keyed on Quarto's + body.quarto-light / body.quarto-dark classes, loaded by the + filter, with token values equal to the shared stylesheet's + light and dark palettes (drift guard), so an OS dark + preference cannot darken a light book. + +Negative: the runtime removing the prompt with the static block; idle badge +and empty output shown for an exercise that has not run; an unlabeled key +input; widget colors switching on prefers-color-scheme alone. + +Usage: uv run --no-project python scripts/tests/test_quarto_display.py +""" + +from __future__ import annotations + +import re +import shutil +import socket +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +EXT_DIR = REPO_ROOT / "_extensions" / "blendtutor" +THEME_CSS = EXT_DIR / "assets" / "quarto-theme.css" +LUA_FILTER = EXT_DIR / "blendtutor.lua" +SHARED_CSS = REPO_ROOT / "crates" / "core" / "assets" / "shared" / "styles.css" +PROMPT_MARKER = "DISPLAY-PROMPT-MARKER" + +FIXTURE_QMD = f"""--- +title: Display fixture +filters: + - mcmullarkey/blendtutor +--- + +::: {{.blendtutor language="r"}} +{PROMPT_MARKER}: write pseudocode as comments. + +```r +# Your comments here +``` +::: +""" + +PASS = 0 +FAIL = 0 + + +def ok(msg: str) -> None: + global PASS + PASS += 1 + print(f" PASS: {msg}") + + +def ko(msg: str) -> None: + global FAIL + FAIL += 1 + print(f" FAIL: {msg}") + + +def check(cond: bool, msg: str) -> None: + if cond: + ok(msg) + else: + ko(msg) + + +# --------------------------------------------------------------------------- +# D4: theme stylesheet keyed on Quarto's page class (static) +# --------------------------------------------------------------------------- + + +def color_tokens(block: str) -> dict[str, str]: + """Map every --bt-color-* custom property in a CSS block to its value.""" + return {m.group(1): m.group(2).strip() for m in re.finditer(r"(--bt-color-[\w-]+)\s*:\s*([^;]+);", block)} + + +def block_after(css: str, opener: str) -> str: + """The brace-balanced body of the first block whose header matches `opener`.""" + match = re.search(opener, css) + if not match: + return "" + start = css.index("{", match.start()) + 1 + depth = 1 + for i in range(start, len(css)): + if css[i] == "{": + depth += 1 + elif css[i] == "}": + depth -= 1 + if depth == 0: + return css[start:i] + return "" + + +def check_theme_stylesheet() -> None: + print("== D4: theme follows the Quarto page class ==") + if not THEME_CSS.exists(): + ko("quarto-theme.css exists in the extension assets") + return + ok("quarto-theme.css exists in the extension assets") + theme = THEME_CSS.read_text() + shared = SHARED_CSS.read_text() + light_expected = color_tokens(block_after(shared, r":root\s*\{")) + dark_media = block_after(shared, r"@media \(prefers-color-scheme: dark\)") + dark_expected = color_tokens(block_after(dark_media, r":root\s*\{")) + light_actual = color_tokens(block_after(theme, r"body\.quarto-light\s*\{")) + dark_actual = color_tokens(block_after(theme, r"body\.quarto-dark\s*\{")) + check(bool(light_expected) and light_actual == light_expected, + "body.quarto-light re-declares every shared light color token") + check(bool(dark_expected) and dark_actual == {k: v for k, v in dark_expected.items()}, + "body.quarto-dark declares the shared dark color tokens") + check("assets/quarto-theme.css" in LUA_FILTER.read_text(), + "the filter's html dependency ships quarto-theme.css") + + +# --------------------------------------------------------------------------- +# Render + serve + browser (D1-D3) +# --------------------------------------------------------------------------- + + +def render_fixture(workdir: Path) -> Path | None: + shutil.copytree(EXT_DIR, workdir / "_extensions" / "mcmullarkey" / "blendtutor") + (workdir / "display.qmd").write_text(FIXTURE_QMD) + result = subprocess.run( + ["quarto", "render", "display.qmd", "--to", "html"], + cwd=workdir, capture_output=True, text=True, timeout=300, check=False, + ) + html = workdir / "display.html" + if result.returncode != 0 or not html.exists(): + ko(f"render display fixture -- exit {result.returncode}: {result.stderr[-400:]}") + return None + ok("render display fixture") + check("quarto-theme.css" in html.read_text(), "rendered page links quarto-theme.css") + return html + + +def free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def rodney(*args: str, timeout: int = 60) -> str: + result = subprocess.run( + ["uvx", "rodney", *args], capture_output=True, text=True, timeout=timeout, check=False, + ) + return result.stdout.strip() + + +def js(expr: str) -> str: + out = rodney("js", expr) + return out.splitlines()[-1].strip() if out else "" + + +def wait_for(expr: str, seconds: int) -> bool: + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if js(expr) == "true": + return True + time.sleep(1) + return False + + +def check_browser(url: str) -> None: + print("== D1-D3: rendered widget in a real browser ==") + rodney("start") + try: + rodney("open", url) + js("localStorage.clear()") + mounted = wait_for("!!document.querySelector('.bt-exercise .bt-editor')", 90) + check(mounted, "runtime mounts the editor") + if not mounted: + return + check( + js(f"document.querySelector('.bt-exercise').innerText.includes('{PROMPT_MARKER}')") == "true", + "D1: the prompt stays visible after the editor mounts", + ) + check( + js("(() => { const w = document.querySelector('.bt-exercise'); const s = w.querySelector('.bt-status'); const o = w.querySelector('.bt-output'); return !!s && !!o && s.getClientRects().length === 0 && o.getClientRects().length === 0; })()") == "true", + "D2: status badge and output box are not rendered before a run", + ) + rodney("click", ".bt-exercise .bt-feedback-btn") + has_input = wait_for("!!document.querySelector('.bt-exercise [data-byok=key-input]')", 15) + check(has_input, "Get feedback without a key mounts the inline key form") + if not has_input: + return + check( + js("(() => { const i = document.querySelector('.bt-exercise [data-byok=key-input]'); return [...i.labels].some((l) => l.textContent.includes('Fireworks API key')); })()") == "true", + "D3: the key input is labeled 'Fireworks API key'", + ) + check( + js("document.querySelector('.bt-exercise [data-byok=key-input]').placeholder.startsWith('fw_')") == "true", + "D3: the key input has an fw_ placeholder", + ) + check( + js("document.querySelector('.bt-exercise [data-byok=feedback]').innerText.includes('stored only in this browser')") == "true", + "D3: the inline form explains the key is stored only in this browser", + ) + finally: + rodney("stop") + + +def main() -> int: + check_theme_stylesheet() + if not shutil.which("quarto") or not shutil.which("uvx"): + ko("quarto and uvx are required for the browser checks") + else: + with tempfile.TemporaryDirectory(prefix="bt-display-") as tmp: + html = render_fixture(Path(tmp)) + if html is not None: + port = free_port() + server = subprocess.Popen( + [sys.executable, "-m", "http.server", str(port), "--bind", "127.0.0.1", "--directory", tmp], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + try: + time.sleep(1) + check_browser(f"http://127.0.0.1:{port}/display.html") + finally: + server.terminate() + print(f"=== Results: {PASS} passed, {FAIL} failed ===") + return 1 if FAIL else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 9f895da972dd2d2872a8079d6699e86584652d2b Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 15:14:10 -0600 Subject: [PATCH 20/27] fix(review): display test compares tokens case-insensitively and anchors its CSS lookups Token values are lowercased before comparison, body.quarto-light/quarto-dark selectors are anchored so a future -dim variant cannot match, and the :root and @media (prefers-color-scheme: dark) openers are anchored to line starts: the stylesheet header comment mentions both, so an unanchored search read the wrong block and saw an empty dark palette. --- scripts/tests/test_quarto_display.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/scripts/tests/test_quarto_display.py b/scripts/tests/test_quarto_display.py index 4fd9f23..a7d62c8 100644 --- a/scripts/tests/test_quarto_display.py +++ b/scripts/tests/test_quarto_display.py @@ -87,8 +87,12 @@ def check(cond: bool, msg: str) -> None: def color_tokens(block: str) -> dict[str, str]: - """Map every --bt-color-* custom property in a CSS block to its value.""" - return {m.group(1): m.group(2).strip() for m in re.finditer(r"(--bt-color-[\w-]+)\s*:\s*([^;]+);", block)} + """Map every --bt-color-* custom property in a CSS block to its value, + lowercased so equal hex colors compare equal regardless of case.""" + return { + m.group(1): m.group(2).strip().lower() + for m in re.finditer(r"(--bt-color-[\w-]+)\s*:\s*([^;]+);", block) + } def block_after(css: str, opener: str) -> str: @@ -116,14 +120,17 @@ def check_theme_stylesheet() -> None: ok("quarto-theme.css exists in the extension assets") theme = THEME_CSS.read_text() shared = SHARED_CSS.read_text() - light_expected = color_tokens(block_after(shared, r":root\s*\{")) - dark_media = block_after(shared, r"@media \(prefers-color-scheme: dark\)") - dark_expected = color_tokens(block_after(dark_media, r":root\s*\{")) - light_actual = color_tokens(block_after(theme, r"body\.quarto-light\s*\{")) - dark_actual = color_tokens(block_after(theme, r"body\.quarto-dark\s*\{")) + light_expected = color_tokens(block_after(shared, r"(?m)^:root\s*\{")) + # (?m)^ anchors to real at-rules: the stylesheet header comment also + # mentions "@media (prefers-color-scheme: dark)" and ":root". + dark_media = block_after(shared, r"(?m)^@media \(prefers-color-scheme: dark\)\s*\{") + dark_expected = color_tokens(block_after(dark_media, r"(?m)^\s*:root\s*\{")) + # (?![\w-]) keeps a future body.quarto-light-dim from matching. + light_actual = color_tokens(block_after(theme, r"body\.quarto-light(?![\w-])\s*\{")) + dark_actual = color_tokens(block_after(theme, r"body\.quarto-dark(?![\w-])\s*\{")) check(bool(light_expected) and light_actual == light_expected, "body.quarto-light re-declares every shared light color token") - check(bool(dark_expected) and dark_actual == {k: v for k, v in dark_expected.items()}, + check(bool(dark_expected) and dark_actual == dark_expected, "body.quarto-dark declares the shared dark color tokens") check("assets/quarto-theme.css" in LUA_FILTER.read_text(), "the filter's html dependency ships quarto-theme.css") From 84bec12dba430365aa9f11dd2908ffd83f7687f8 Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 15:15:14 -0600 Subject: [PATCH 21/27] docs: ADR-0021 Quarto widget follows the page theme and hides idle chrome --- ...021-quarto-widget-theme-and-idle-chrome.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/adr/0021-quarto-widget-theme-and-idle-chrome.md diff --git a/docs/adr/0021-quarto-widget-theme-and-idle-chrome.md b/docs/adr/0021-quarto-widget-theme-and-idle-chrome.md new file mode 100644 index 0000000..81e807e --- /dev/null +++ b/docs/adr/0021-quarto-widget-theme-and-idle-chrome.md @@ -0,0 +1,52 @@ +# ADR-0021: Quarto widget follows the page theme; idle chrome stays hidden + +- Status: Accepted +- Date: 2026-09-13 + +## Context + +In a light Quarto book viewed on a machine set to dark mode, the exercise +widget renders as a dark panel: `styles.css` (synced from +`crates/core/assets/shared/styles.css`, ADR-0010) switches every +`--bt-color-*` token under `@media (prefers-color-scheme: dark)`, which is the +right signal for the CLI-built site (it owns the whole page) but the wrong one +inside a Quarto page, whose theme is chosen by the author and marked on +`` as `quarto-light` or `quarto-dark`. The same page also showed an +"IDLE" badge and an empty output box for an exercise that had never run, lost +its prompt when the runtime removed the static fallback block, and offered an +unlabeled password input for the API key. + +## Options + +1. **Change the shared stylesheet** to key on a page class. Couples the CLI + site to Quarto's class names and breaks its OS-driven dark mode. +2. **Transform the dark media block in `sync-quarto-assets.sh`.** Keeps one + source, but hides a theme decision inside a CSS rewriter already carrying + scoping rules, and the rewritten output is hard to read and review. +3. **Ship an extension-only `quarto-theme.css`** that re-declares the color + tokens on `body.quarto-light` (light values) and `body.quarto-dark` (dark + values). Custom properties declared on `body` override the `:root` + declarations for everything inside it, whatever the OS prefers. + +## Decision + +Option 3. The filter's html dependency lists `assets/quarto-theme.css` after +`styles.css`. A test pins its token values to the shared stylesheet's light +`:root` and dark-media `:root` blocks, so the duplicated palette cannot drift. + +In the same slice the runtime keeps the widget legible: + +- the prompt element is moved out of the static fallback before it is removed; +- the status badge and output box are created `hidden` and revealed by the + first run, so exercises without checks never show them; +- the key form labels its input ("Fireworks API key"), shows an `fw_` + placeholder, and says the key is stored only in the browser. + +## Consequences + +- Light books stay light and dark books stay dark regardless of OS settings. +- CodeMirror's dark-only tweaks inside the media block (gutter border, active + line tint) still follow the OS; they are subtle on either surface and are + left for a follow-up if they read poorly. +- Probes that look up `.bt-status` keep working: the element exists from + mount, only its rendering is deferred. From c089b955f1c9031ad416201d7b20356766504e98 Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 15:16:12 -0600 Subject: [PATCH 22/27] feat: Quarto widget keeps its prompt, hides idle chrome, labels the key form, follows the page theme The runtime moves the prompt out of the static fallback before removing it, and creates the status badge and output box hidden until a run reveals them. The inline key form explains itself and labels its input with an fw_ placeholder. A new quarto-theme.css re-declares the color tokens on body.quarto-light/quarto-dark so a light book stays light on a dark-mode OS; shared styles.css gains [hidden]/:empty display rules, resynced to the extension. Demo-book copies synced. --- .../blendtutor/assets/exercise-runtime.js | 21 ++++++- _extensions/blendtutor/assets/key-page.js | 20 ++++++- .../blendtutor/assets/quarto-theme.css | 58 +++++++++++++++++++ _extensions/blendtutor/assets/styles.css | 10 ++++ _extensions/blendtutor/blendtutor.lua | 2 +- crates/core/assets/shared/styles.css | 9 +++ .../blendtutor/assets/exercise-runtime.js | 21 ++++++- .../mcmullarkey/blendtutor/assets/key-page.js | 20 ++++++- .../blendtutor/assets/quarto-theme.css | 58 +++++++++++++++++++ .../mcmullarkey/blendtutor/assets/styles.css | 10 ++++ .../mcmullarkey/blendtutor/blendtutor.lua | 2 +- 11 files changed, 221 insertions(+), 10 deletions(-) create mode 100644 _extensions/blendtutor/assets/quarto-theme.css create mode 100644 demo-book/_extensions/mcmullarkey/blendtutor/assets/quarto-theme.css diff --git a/_extensions/blendtutor/assets/exercise-runtime.js b/_extensions/blendtutor/assets/exercise-runtime.js index bb94269..553507e 100644 --- a/_extensions/blendtutor/assets/exercise-runtime.js +++ b/_extensions/blendtutor/assets/exercise-runtime.js @@ -175,16 +175,25 @@ export function buildRegistry(entries) { * when JS never runs (file:// CORS-blocks ES modules, JS disabled). * Progressive enhancement: when the runtime boots, this block is replaced by * the interactive editor UI — remove it BEFORE mounting so the static content - * never sits alongside the editor. Exercises that are skipped (no + * never sits alongside the editor. The prompt is kept (re-classed + * `.bt-prompt`): learners still need the instructions once the editor mounts. Exercises that are skipped (no * data-language, no adapter) KEEP their static block — the page degrades to * readable content instead of nothing. * @param {HTMLElement} element — The div.bt-exercise element. */ function removeStaticFallback(element) { const fallback = element.querySelector(".bt-exercise-static"); - if (fallback) { - fallback.remove(); + if (!fallback) { + return; + } + // ADR-0021: the prompt is the exercise's instructions, not fallback-only + // content — move it out (where the block sat) before dropping the rest. + const prompt = fallback.querySelector(".bt-static-prompt"); + if (prompt) { + prompt.className = "bt-prompt"; + fallback.before(prompt); } + fallback.remove(); } /** @@ -354,10 +363,14 @@ function wireExercise(entry, runtime) { statusEl.className = "bt-status"; statusEl.dataset.status = "idle"; statusEl.textContent = "idle"; + // ADR-0021: idle chrome stays hidden until something runs, so an exercise + // without checks never shows an "idle" badge or an empty output box. + statusEl.hidden = true; entry.element.appendChild(statusEl); const outputEl = document.createElement("div"); outputEl.className = "bt-output"; + outputEl.hidden = true; entry.element.appendChild(outputEl); // Per-exercise getSubmission — reads THIS exercise's editor (§3.4). @@ -387,6 +400,7 @@ function wireExercise(entry, runtime) { entry.setStatus = function (state, text) { statusEl.dataset.status = state; statusEl.textContent = text ?? state; + statusEl.hidden = state === "idle"; }; // Per-exercise runSubmission — evaluates via the injected runtime adapter. @@ -413,6 +427,7 @@ function wireExercise(entry, runtime) { entry.payload.packages ?? [], ); outputEl.textContent = output; + outputEl.hidden = false; entry.setStatus(ok ? "pass" : "fail", ok ? "pass" : "fail"); return ok ? "pass" : "fail"; } finally { diff --git a/_extensions/blendtutor/assets/key-page.js b/_extensions/blendtutor/assets/key-page.js index b2da8da..99ea435 100644 --- a/_extensions/blendtutor/assets/key-page.js +++ b/_extensions/blendtutor/assets/key-page.js @@ -68,6 +68,10 @@ export function statusMessage(reason) { // --- effectful shell (DOM wiring, fetch, storage) ------------------------------ +// Placeholder for the key input: browser BYOK is Fireworks-only (keys look +// like fw_...), so the hint matches what learners paste. +const KEY_PLACEHOLDER = "fw_..."; + // Guard against double-mounting: mounting the same container twice must not // duplicate the submit listener (one save must issue exactly one fetch). const mountedTargets = new WeakSet(); @@ -107,18 +111,32 @@ function renderKeyForm(container, initialReason, onSaved) { const status = document.createElement("p"); status.dataset.byok = "key-status"; + // ADR-0021: say what the input wants and where the key lives, so the inline + // form is self-explanatory when Get feedback mounts it without a key. + const providerLabel = PROVIDERS[PROVIDER_ID].label; + const hint = document.createElement("p"); + hint.dataset.byok = "key-hint"; + hint.textContent = + "Paste your " + providerLabel + " API key to get AI feedback. It is stored only in this browser."; + const input = document.createElement("input"); input.type = "password"; input.name = "byok-key"; input.autocomplete = "off"; + input.placeholder = KEY_PLACEHOLDER; input.dataset.byok = "key-input"; + const label = document.createElement("label"); + label.dataset.byok = "key-label"; + label.textContent = providerLabel + " API key "; + label.append(input); + const save = document.createElement("button"); save.type = "submit"; save.dataset.byok = "save"; save.textContent = "Save key"; - form.append(status, input, save); + form.append(hint, label, save, status); form.addEventListener("submit", (event) => { event.preventDefault(); return handleSave(input, status, onSaved); diff --git a/_extensions/blendtutor/assets/quarto-theme.css b/_extensions/blendtutor/assets/quarto-theme.css new file mode 100644 index 0000000..78c3dba --- /dev/null +++ b/_extensions/blendtutor/assets/quarto-theme.css @@ -0,0 +1,58 @@ +/* quarto-theme.css — Quarto extension only (ADR-0021). + * + * WHAT: Re-declares the widget's --bt-color-* tokens from Quarto's page class + * (body.quarto-light / body.quarto-dark) so exercises follow the book's + * theme instead of the OS prefers-color-scheme setting. + * WHERE: Listed after styles.css in blendtutor.lua's html dependency. + * NOT: No layout or component rules. Token VALUES mirror + * crates/core/assets/shared/styles.css (light :root and the dark media + * :root); scripts/tests/test_quarto_display.py pins them. + */ + +body.quarto-light { + --bt-color-surface: #ffffff; + --bt-color-surface-code: #f5f5f5; + --bt-color-text-primary: #1a1a1a; + --bt-color-text-secondary: #555555; + --bt-color-status-pass: #0a7d28; + --bt-color-status-fail: #c0202a; + --bt-color-status-running: #b06a00; + --bt-color-brand: #1a1a1a; + --bt-color-brand-hover: #000000; + --bt-color-border: #d4d4d4; + --bt-color-status-idle: #e8e8e8; + --bt-color-success-bg: #e8f5e9; + --bt-color-danger-bg: #fff3e0; + --bt-color-syntax-keyword: #055; + --bt-color-syntax-string: #a11; + --bt-color-syntax-number: #708; + --bt-color-syntax-comment: #6a6a6a; + --bt-color-syntax-variable: #000; + --bt-color-syntax-function: #30a; + --bt-color-syntax-operator: #000; + --bt-color-cursor: #1a1a1a; +} + +body.quarto-dark { + --bt-color-surface: #1e1e1e; + --bt-color-surface-code: #2d2d2d; + --bt-color-text-primary: #e0e0e0; + --bt-color-text-secondary: #a0a0a0; + --bt-color-status-pass: #66bb6a; + --bt-color-status-fail: #ff5252; + --bt-color-status-running: #ff9800; + --bt-color-brand: #e0e0e0; + --bt-color-brand-hover: #ffffff; + --bt-color-border: #808080; + --bt-color-status-idle: #888888; + --bt-color-success-bg: #1b3d1b; + --bt-color-danger-bg: #3d1b1b; + --bt-color-syntax-keyword: #569cd6; + --bt-color-syntax-string: #ce9178; + --bt-color-syntax-number: #b5cea8; + --bt-color-syntax-comment: #6a9955; + --bt-color-syntax-variable: #d4d4d4; + --bt-color-syntax-function: #dcdcaa; + --bt-color-syntax-operator: #d4d4d4; + --bt-color-cursor: #ffffff; +} diff --git a/_extensions/blendtutor/assets/styles.css b/_extensions/blendtutor/assets/styles.css index 1b394d9..5716f9c 100644 --- a/_extensions/blendtutor/assets/styles.css +++ b/_extensions/blendtutor/assets/styles.css @@ -656,6 +656,16 @@ color: var(--bt-color-surface); } +/* ADR-0021: status and output render only once something has run; the + * feedback area collapses until it has content. Attribute selectors beat the + * component display rules above, which would otherwise override [hidden]. */ + +.bt-exercise .bt-status[hidden], +.bt-exercise .bt-output[hidden], +.bt-exercise .bt-feedback:empty { + display: none; +} + .bt-exercise .bt-output { background: var(--bt-color-surface-code); border: 1px solid var(--bt-color-border); diff --git a/_extensions/blendtutor/blendtutor.lua b/_extensions/blendtutor/blendtutor.lua index 654e9d5..29ed62e 100644 --- a/_extensions/blendtutor/blendtutor.lua +++ b/_extensions/blendtutor/blendtutor.lua @@ -244,7 +244,7 @@ local function build_html_dependency() quarto.doc.add_html_dependency({ name = "blendtutor", version = BT_DEP_VERSION, - stylesheets = { "assets/styles.css" }, + stylesheets = { "assets/styles.css", "assets/quarto-theme.css" }, resources = resources, }) end diff --git a/crates/core/assets/shared/styles.css b/crates/core/assets/shared/styles.css index 39c146a..d3e11cb 100644 --- a/crates/core/assets/shared/styles.css +++ b/crates/core/assets/shared/styles.css @@ -631,6 +631,15 @@ button[disabled] { color: var(--bt-color-surface); } +/* ADR-0021: status and output render only once something has run; the + * feedback area collapses until it has content. Attribute selectors beat the + * component display rules above, which would otherwise override [hidden]. */ +.bt-status[hidden], +.bt-output[hidden], +.bt-feedback:empty { + display: none; +} + .bt-output { background: var(--bt-color-surface-code); border: 1px solid var(--bt-color-border); diff --git a/demo-book/_extensions/mcmullarkey/blendtutor/assets/exercise-runtime.js b/demo-book/_extensions/mcmullarkey/blendtutor/assets/exercise-runtime.js index bb94269..553507e 100644 --- a/demo-book/_extensions/mcmullarkey/blendtutor/assets/exercise-runtime.js +++ b/demo-book/_extensions/mcmullarkey/blendtutor/assets/exercise-runtime.js @@ -175,16 +175,25 @@ export function buildRegistry(entries) { * when JS never runs (file:// CORS-blocks ES modules, JS disabled). * Progressive enhancement: when the runtime boots, this block is replaced by * the interactive editor UI — remove it BEFORE mounting so the static content - * never sits alongside the editor. Exercises that are skipped (no + * never sits alongside the editor. The prompt is kept (re-classed + * `.bt-prompt`): learners still need the instructions once the editor mounts. Exercises that are skipped (no * data-language, no adapter) KEEP their static block — the page degrades to * readable content instead of nothing. * @param {HTMLElement} element — The div.bt-exercise element. */ function removeStaticFallback(element) { const fallback = element.querySelector(".bt-exercise-static"); - if (fallback) { - fallback.remove(); + if (!fallback) { + return; + } + // ADR-0021: the prompt is the exercise's instructions, not fallback-only + // content — move it out (where the block sat) before dropping the rest. + const prompt = fallback.querySelector(".bt-static-prompt"); + if (prompt) { + prompt.className = "bt-prompt"; + fallback.before(prompt); } + fallback.remove(); } /** @@ -354,10 +363,14 @@ function wireExercise(entry, runtime) { statusEl.className = "bt-status"; statusEl.dataset.status = "idle"; statusEl.textContent = "idle"; + // ADR-0021: idle chrome stays hidden until something runs, so an exercise + // without checks never shows an "idle" badge or an empty output box. + statusEl.hidden = true; entry.element.appendChild(statusEl); const outputEl = document.createElement("div"); outputEl.className = "bt-output"; + outputEl.hidden = true; entry.element.appendChild(outputEl); // Per-exercise getSubmission — reads THIS exercise's editor (§3.4). @@ -387,6 +400,7 @@ function wireExercise(entry, runtime) { entry.setStatus = function (state, text) { statusEl.dataset.status = state; statusEl.textContent = text ?? state; + statusEl.hidden = state === "idle"; }; // Per-exercise runSubmission — evaluates via the injected runtime adapter. @@ -413,6 +427,7 @@ function wireExercise(entry, runtime) { entry.payload.packages ?? [], ); outputEl.textContent = output; + outputEl.hidden = false; entry.setStatus(ok ? "pass" : "fail", ok ? "pass" : "fail"); return ok ? "pass" : "fail"; } finally { diff --git a/demo-book/_extensions/mcmullarkey/blendtutor/assets/key-page.js b/demo-book/_extensions/mcmullarkey/blendtutor/assets/key-page.js index b2da8da..99ea435 100644 --- a/demo-book/_extensions/mcmullarkey/blendtutor/assets/key-page.js +++ b/demo-book/_extensions/mcmullarkey/blendtutor/assets/key-page.js @@ -68,6 +68,10 @@ export function statusMessage(reason) { // --- effectful shell (DOM wiring, fetch, storage) ------------------------------ +// Placeholder for the key input: browser BYOK is Fireworks-only (keys look +// like fw_...), so the hint matches what learners paste. +const KEY_PLACEHOLDER = "fw_..."; + // Guard against double-mounting: mounting the same container twice must not // duplicate the submit listener (one save must issue exactly one fetch). const mountedTargets = new WeakSet(); @@ -107,18 +111,32 @@ function renderKeyForm(container, initialReason, onSaved) { const status = document.createElement("p"); status.dataset.byok = "key-status"; + // ADR-0021: say what the input wants and where the key lives, so the inline + // form is self-explanatory when Get feedback mounts it without a key. + const providerLabel = PROVIDERS[PROVIDER_ID].label; + const hint = document.createElement("p"); + hint.dataset.byok = "key-hint"; + hint.textContent = + "Paste your " + providerLabel + " API key to get AI feedback. It is stored only in this browser."; + const input = document.createElement("input"); input.type = "password"; input.name = "byok-key"; input.autocomplete = "off"; + input.placeholder = KEY_PLACEHOLDER; input.dataset.byok = "key-input"; + const label = document.createElement("label"); + label.dataset.byok = "key-label"; + label.textContent = providerLabel + " API key "; + label.append(input); + const save = document.createElement("button"); save.type = "submit"; save.dataset.byok = "save"; save.textContent = "Save key"; - form.append(status, input, save); + form.append(hint, label, save, status); form.addEventListener("submit", (event) => { event.preventDefault(); return handleSave(input, status, onSaved); diff --git a/demo-book/_extensions/mcmullarkey/blendtutor/assets/quarto-theme.css b/demo-book/_extensions/mcmullarkey/blendtutor/assets/quarto-theme.css new file mode 100644 index 0000000..78c3dba --- /dev/null +++ b/demo-book/_extensions/mcmullarkey/blendtutor/assets/quarto-theme.css @@ -0,0 +1,58 @@ +/* quarto-theme.css — Quarto extension only (ADR-0021). + * + * WHAT: Re-declares the widget's --bt-color-* tokens from Quarto's page class + * (body.quarto-light / body.quarto-dark) so exercises follow the book's + * theme instead of the OS prefers-color-scheme setting. + * WHERE: Listed after styles.css in blendtutor.lua's html dependency. + * NOT: No layout or component rules. Token VALUES mirror + * crates/core/assets/shared/styles.css (light :root and the dark media + * :root); scripts/tests/test_quarto_display.py pins them. + */ + +body.quarto-light { + --bt-color-surface: #ffffff; + --bt-color-surface-code: #f5f5f5; + --bt-color-text-primary: #1a1a1a; + --bt-color-text-secondary: #555555; + --bt-color-status-pass: #0a7d28; + --bt-color-status-fail: #c0202a; + --bt-color-status-running: #b06a00; + --bt-color-brand: #1a1a1a; + --bt-color-brand-hover: #000000; + --bt-color-border: #d4d4d4; + --bt-color-status-idle: #e8e8e8; + --bt-color-success-bg: #e8f5e9; + --bt-color-danger-bg: #fff3e0; + --bt-color-syntax-keyword: #055; + --bt-color-syntax-string: #a11; + --bt-color-syntax-number: #708; + --bt-color-syntax-comment: #6a6a6a; + --bt-color-syntax-variable: #000; + --bt-color-syntax-function: #30a; + --bt-color-syntax-operator: #000; + --bt-color-cursor: #1a1a1a; +} + +body.quarto-dark { + --bt-color-surface: #1e1e1e; + --bt-color-surface-code: #2d2d2d; + --bt-color-text-primary: #e0e0e0; + --bt-color-text-secondary: #a0a0a0; + --bt-color-status-pass: #66bb6a; + --bt-color-status-fail: #ff5252; + --bt-color-status-running: #ff9800; + --bt-color-brand: #e0e0e0; + --bt-color-brand-hover: #ffffff; + --bt-color-border: #808080; + --bt-color-status-idle: #888888; + --bt-color-success-bg: #1b3d1b; + --bt-color-danger-bg: #3d1b1b; + --bt-color-syntax-keyword: #569cd6; + --bt-color-syntax-string: #ce9178; + --bt-color-syntax-number: #b5cea8; + --bt-color-syntax-comment: #6a9955; + --bt-color-syntax-variable: #d4d4d4; + --bt-color-syntax-function: #dcdcaa; + --bt-color-syntax-operator: #d4d4d4; + --bt-color-cursor: #ffffff; +} diff --git a/demo-book/_extensions/mcmullarkey/blendtutor/assets/styles.css b/demo-book/_extensions/mcmullarkey/blendtutor/assets/styles.css index 1b394d9..5716f9c 100644 --- a/demo-book/_extensions/mcmullarkey/blendtutor/assets/styles.css +++ b/demo-book/_extensions/mcmullarkey/blendtutor/assets/styles.css @@ -656,6 +656,16 @@ color: var(--bt-color-surface); } +/* ADR-0021: status and output render only once something has run; the + * feedback area collapses until it has content. Attribute selectors beat the + * component display rules above, which would otherwise override [hidden]. */ + +.bt-exercise .bt-status[hidden], +.bt-exercise .bt-output[hidden], +.bt-exercise .bt-feedback:empty { + display: none; +} + .bt-exercise .bt-output { background: var(--bt-color-surface-code); border: 1px solid var(--bt-color-border); diff --git a/demo-book/_extensions/mcmullarkey/blendtutor/blendtutor.lua b/demo-book/_extensions/mcmullarkey/blendtutor/blendtutor.lua index 654e9d5..29ed62e 100644 --- a/demo-book/_extensions/mcmullarkey/blendtutor/blendtutor.lua +++ b/demo-book/_extensions/mcmullarkey/blendtutor/blendtutor.lua @@ -244,7 +244,7 @@ local function build_html_dependency() quarto.doc.add_html_dependency({ name = "blendtutor", version = BT_DEP_VERSION, - stylesheets = { "assets/styles.css" }, + stylesheets = { "assets/styles.css", "assets/quarto-theme.css" }, resources = resources, }) end From 0e7d7f838e5cbce02ce5d0462f0fd1feb7caedeb Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 15:21:57 -0600 Subject: [PATCH 23/27] fix(review): style the kept prompt and surface errors from a throwing run The prompt moved out of the static fallback had no CSS once re-classed .bt-prompt; it now gets the fallback prompt rhythm in primary color, and the display test asserts the rule applies. A runtime.run() that throws no longer leaves the badge on running with the output hidden: the error is written to the output, shown, and the run marked failed. --- _extensions/blendtutor/assets/exercise-runtime.js | 8 ++++++++ _extensions/blendtutor/assets/styles.css | 10 ++++++++++ crates/core/assets/shared/styles.css | 9 +++++++++ .../mcmullarkey/blendtutor/assets/exercise-runtime.js | 8 ++++++++ .../mcmullarkey/blendtutor/assets/styles.css | 10 ++++++++++ scripts/tests/test_quarto_display.py | 4 ++++ 6 files changed, 49 insertions(+) diff --git a/_extensions/blendtutor/assets/exercise-runtime.js b/_extensions/blendtutor/assets/exercise-runtime.js index 553507e..42f2007 100644 --- a/_extensions/blendtutor/assets/exercise-runtime.js +++ b/_extensions/blendtutor/assets/exercise-runtime.js @@ -430,6 +430,14 @@ function wireExercise(entry, runtime) { outputEl.hidden = false; entry.setStatus(ok ? "pass" : "fail", ok ? "pass" : "fail"); return ok ? "pass" : "fail"; + } catch (err) { + // A throwing adapter (boot failure, runtime crash) must not leave the + // badge stuck on "running" with the output hidden (ADR-0021 hides both + // until a run): surface the error and mark the run failed. + outputEl.textContent = `Error: ${err && err.message ? err.message : err}`; + outputEl.hidden = false; + entry.setStatus("fail", "fail"); + return "fail"; } finally { entry._running = false; if (entry.checkBtn) entry.checkBtn.disabled = false; diff --git a/_extensions/blendtutor/assets/styles.css b/_extensions/blendtutor/assets/styles.css index 5716f9c..8e7fdc2 100644 --- a/_extensions/blendtutor/assets/styles.css +++ b/_extensions/blendtutor/assets/styles.css @@ -159,6 +159,16 @@ margin-bottom: var(--bt-space-md); } +/* ADR-0021: the prompt the runtime keeps after removing the static fallback. + * Same rhythm as the fallback prompt, primary color: these are the live + * instructions, not fallback prose. */ + +.bt-exercise .bt-prompt { + color: var(--bt-color-text-primary); + line-height: var(--bt-line-height); + margin-bottom: var(--bt-space-md); +} + .bt-exercise .bt-exercise-static pre.bt-static-code { font-family: var(--bt-font-family-code); font-size: var(--bt-font-size-base); diff --git a/crates/core/assets/shared/styles.css b/crates/core/assets/shared/styles.css index d3e11cb..ce012f3 100644 --- a/crates/core/assets/shared/styles.css +++ b/crates/core/assets/shared/styles.css @@ -166,6 +166,15 @@ footer.site-footer { margin-bottom: var(--bt-space-md); } +/* ADR-0021: the prompt the runtime keeps after removing the static fallback. + * Same rhythm as the fallback prompt, primary color: these are the live + * instructions, not fallback prose. */ +.bt-prompt { + color: var(--bt-color-text-primary); + line-height: var(--bt-line-height); + margin-bottom: var(--bt-space-md); +} + .bt-exercise-static pre.bt-static-code { font-family: var(--bt-font-family-code); font-size: var(--bt-font-size-base); diff --git a/demo-book/_extensions/mcmullarkey/blendtutor/assets/exercise-runtime.js b/demo-book/_extensions/mcmullarkey/blendtutor/assets/exercise-runtime.js index 553507e..42f2007 100644 --- a/demo-book/_extensions/mcmullarkey/blendtutor/assets/exercise-runtime.js +++ b/demo-book/_extensions/mcmullarkey/blendtutor/assets/exercise-runtime.js @@ -430,6 +430,14 @@ function wireExercise(entry, runtime) { outputEl.hidden = false; entry.setStatus(ok ? "pass" : "fail", ok ? "pass" : "fail"); return ok ? "pass" : "fail"; + } catch (err) { + // A throwing adapter (boot failure, runtime crash) must not leave the + // badge stuck on "running" with the output hidden (ADR-0021 hides both + // until a run): surface the error and mark the run failed. + outputEl.textContent = `Error: ${err && err.message ? err.message : err}`; + outputEl.hidden = false; + entry.setStatus("fail", "fail"); + return "fail"; } finally { entry._running = false; if (entry.checkBtn) entry.checkBtn.disabled = false; diff --git a/demo-book/_extensions/mcmullarkey/blendtutor/assets/styles.css b/demo-book/_extensions/mcmullarkey/blendtutor/assets/styles.css index 5716f9c..8e7fdc2 100644 --- a/demo-book/_extensions/mcmullarkey/blendtutor/assets/styles.css +++ b/demo-book/_extensions/mcmullarkey/blendtutor/assets/styles.css @@ -159,6 +159,16 @@ margin-bottom: var(--bt-space-md); } +/* ADR-0021: the prompt the runtime keeps after removing the static fallback. + * Same rhythm as the fallback prompt, primary color: these are the live + * instructions, not fallback prose. */ + +.bt-exercise .bt-prompt { + color: var(--bt-color-text-primary); + line-height: var(--bt-line-height); + margin-bottom: var(--bt-space-md); +} + .bt-exercise .bt-exercise-static pre.bt-static-code { font-family: var(--bt-font-family-code); font-size: var(--bt-font-size-base); diff --git a/scripts/tests/test_quarto_display.py b/scripts/tests/test_quarto_display.py index a7d62c8..ccf45d6 100644 --- a/scripts/tests/test_quarto_display.py +++ b/scripts/tests/test_quarto_display.py @@ -198,6 +198,10 @@ def check_browser(url: str) -> None: js(f"document.querySelector('.bt-exercise').innerText.includes('{PROMPT_MARKER}')") == "true", "D1: the prompt stays visible after the editor mounts", ) + check( + js("(() => { const p = document.querySelector('.bt-exercise .bt-prompt'); return !!p && getComputedStyle(p).marginBottom !== '0px'; })()") == "true", + "D1: the kept prompt is styled (.bt-prompt rule applies)", + ) check( js("(() => { const w = document.querySelector('.bt-exercise'); const s = w.querySelector('.bt-status'); const o = w.querySelector('.bt-output'); return !!s && !!o && s.getClientRects().length === 0 && o.getClientRects().length === 0; })()") == "true", "D2: status badge and output box are not rendered before a run", From 7cfdc9124065d602839e03eee3a541e366e9450d Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 15:28:13 -0600 Subject: [PATCH 24/27] chore: bump version to 0.2.0 Workspace crates and the Quarto extension move to 0.2.0 together (the extension had stayed at 0.1.0 through the 0.1.1 CLI release). BT_DEP_VERSION and the blendtutor- libs paths pinned by the Quarto script tests follow. --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- README.md | 2 +- _extensions/blendtutor/_extension.yml | 2 +- _extensions/blendtutor/blendtutor.lua | 6 ++--- .../mcmullarkey/blendtutor/_extension.yml | 2 +- .../mcmullarkey/blendtutor/blendtutor.lua | 6 ++--- scripts/tests/test_quarto_asset_deployment.sh | 24 +++++++++---------- scripts/tests/test_quarto_bootstrap.sh | 10 ++++---- scripts/tests/test_quarto_distribution.sh | 18 +++++++------- scripts/tests/test_quarto_install_render.sh | 8 +++---- scripts/tests/test_quarto_ux.py | 6 ++--- 12 files changed, 45 insertions(+), 45 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0b5d3cf..baad0b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -250,7 +250,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "blendtutor-cli" -version = "0.1.1" +version = "0.2.0" dependencies = [ "anyhow", "assert_cmd", @@ -269,7 +269,7 @@ dependencies = [ [[package]] name = "blendtutor-core" -version = "0.1.1" +version = "0.2.0" dependencies = [ "aes-gcm", "base64", diff --git a/Cargo.toml b/Cargo.toml index c12f002..0e3f68d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/core", "crates/cli"] [workspace.package] -version = "0.1.1" +version = "0.2.0" edition = "2024" license = "MIT" repository = "https://github.com/mcmullarkey/blendtutor" diff --git a/README.md b/README.md index ea14e2e..0ccd3b3 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ checks, solution reveal, AI hints, all static HTML. Requires **Quarto >= 1.4**: quarto add mcmullarkey/blendtutor ``` -Installs to `_extensions/mcmullarkey/blendtutor/` (version 0.1.0). **Run it from the +Installs to `_extensions/mcmullarkey/blendtutor/` (version 0.2.0). **Run it from the folder that contains `_quarto.yml`** (or the `.qmd`): Quarto only discovers `_extensions/` there, so one installed a directory up never loads; assets are install-path-independent. diff --git a/_extensions/blendtutor/_extension.yml b/_extensions/blendtutor/_extension.yml index b8eb309..301cb53 100644 --- a/_extensions/blendtutor/_extension.yml +++ b/_extensions/blendtutor/_extension.yml @@ -1,6 +1,6 @@ title: blendtutor author: blendtutor -version: 0.1.0 +version: 0.2.0 contributes: filters: - blendtutor.lua diff --git a/_extensions/blendtutor/blendtutor.lua b/_extensions/blendtutor/blendtutor.lua index 29ed62e..1c4f788 100644 --- a/_extensions/blendtutor/blendtutor.lua +++ b/_extensions/blendtutor/blendtutor.lua @@ -170,7 +170,7 @@ local COI_SCRIPT_PATH = resolve_asset_path(PANDOC_SCRIPT_FILE, "coi-serviceworke -- Single source of truth for the extension version (AC-4 clause 10). Used in -- BOTH the add_html_dependency declaration AND the emitted libs URL string; -- must equal _extension.yml:3 version. -local BT_DEP_VERSION = "0.1.0" +local BT_DEP_VERSION = "0.2.0" --- Compute the document-relative libs URL for a deployed asset (AC-4, AC-5). -- Quarto deploys add_html_dependency resources + stylesheets to @@ -199,8 +199,8 @@ local BT_DEP_VERSION = "0.1.0" -- (probe-verified quarto 1.10.18) — strip to the basename before the stem. -- @param filename asset basename, e.g. "exercise-runtime.js" -- @return document-relative ES-module-safe libs URL, --- standalone: "./index_files/libs/quarto-contrib/blendtutor-0.1.0/exercise-runtime.js" --- book: "./site_libs/quarto-contrib/blendtutor-0.1.0/exercise-runtime.js" +-- standalone: "./index_files/libs/quarto-contrib/blendtutor-0.2.0/exercise-runtime.js" +-- book: "./site_libs/quarto-contrib/blendtutor-0.2.0/exercise-runtime.js" local function libs_url(filename) local output_file = quarto and quarto.doc and quarto.doc.output_file or "" local basename = output_file:match("^.*[/\\]([^/\\]+)$") or output_file diff --git a/demo-book/_extensions/mcmullarkey/blendtutor/_extension.yml b/demo-book/_extensions/mcmullarkey/blendtutor/_extension.yml index b8eb309..301cb53 100644 --- a/demo-book/_extensions/mcmullarkey/blendtutor/_extension.yml +++ b/demo-book/_extensions/mcmullarkey/blendtutor/_extension.yml @@ -1,6 +1,6 @@ title: blendtutor author: blendtutor -version: 0.1.0 +version: 0.2.0 contributes: filters: - blendtutor.lua diff --git a/demo-book/_extensions/mcmullarkey/blendtutor/blendtutor.lua b/demo-book/_extensions/mcmullarkey/blendtutor/blendtutor.lua index 29ed62e..1c4f788 100644 --- a/demo-book/_extensions/mcmullarkey/blendtutor/blendtutor.lua +++ b/demo-book/_extensions/mcmullarkey/blendtutor/blendtutor.lua @@ -170,7 +170,7 @@ local COI_SCRIPT_PATH = resolve_asset_path(PANDOC_SCRIPT_FILE, "coi-serviceworke -- Single source of truth for the extension version (AC-4 clause 10). Used in -- BOTH the add_html_dependency declaration AND the emitted libs URL string; -- must equal _extension.yml:3 version. -local BT_DEP_VERSION = "0.1.0" +local BT_DEP_VERSION = "0.2.0" --- Compute the document-relative libs URL for a deployed asset (AC-4, AC-5). -- Quarto deploys add_html_dependency resources + stylesheets to @@ -199,8 +199,8 @@ local BT_DEP_VERSION = "0.1.0" -- (probe-verified quarto 1.10.18) — strip to the basename before the stem. -- @param filename asset basename, e.g. "exercise-runtime.js" -- @return document-relative ES-module-safe libs URL, --- standalone: "./index_files/libs/quarto-contrib/blendtutor-0.1.0/exercise-runtime.js" --- book: "./site_libs/quarto-contrib/blendtutor-0.1.0/exercise-runtime.js" +-- standalone: "./index_files/libs/quarto-contrib/blendtutor-0.2.0/exercise-runtime.js" +-- book: "./site_libs/quarto-contrib/blendtutor-0.2.0/exercise-runtime.js" local function libs_url(filename) local output_file = quarto and quarto.doc and quarto.doc.output_file or "" local basename = output_file:match("^.*[/\\]([^/\\]+)$") or output_file diff --git a/scripts/tests/test_quarto_asset_deployment.sh b/scripts/tests/test_quarto_asset_deployment.sh index acfdb8b..15c8036 100644 --- a/scripts/tests/test_quarto_asset_deployment.sh +++ b/scripts/tests/test_quarto_asset_deployment.sh @@ -17,15 +17,15 @@ # has_python. ISSUE #164 (byok-api-key AC-3): assets/exercise-feedback.js # + assets/key-page.js ALWAYS (C1 — the bootstrap imports both). # 3. Deployed to libs: files physically exist at -# _files/libs/quarto-contrib/blendtutor-0.1.0/ — exercise-runtime.js, +# _files/libs/quarto-contrib/blendtutor-0.2.0/ — exercise-runtime.js, # codemirror.js, styles.css always; webr-adapter.js present iff page has R # exercises, ABSENT otherwise (same for pyodide/python); exercise-feedback.js # + key-page.js always (C2). # 4. CSS via Quarto link: rendered HTML contains exactly one -# ; no _extensions/.../assets/styles.css +# ; no _extensions/.../assets/styles.css # link present. # 5. Bootstrap specifiers rewritten: data-bt-bootstrap="auto" module's import -# specifiers reference _files/libs/quarto-contrib/blendtutor-0.1.0/.js, +# specifiers reference _files/libs/quarto-contrib/blendtutor-0.2.0/.js, # computed from quarto.doc.output_file stem; NO _extensions/ substring and # NO resolve_asset_path output in any specifier. # 6. No classic runtime script tag: rendered HTML contains NO @@ -41,7 +41,7 @@ # keeps _files/... (discriminator pin). # 9. Non-HTML gate: hermetic latex render → zero *_files/libs/ dirs created, # zero bootstrap injection. -# 10. Version pin single-sourced: BT_DEP_VERSION = "0.1.0" Lua constant equals +# 10. Version pin single-sourced: BT_DEP_VERSION = "0.2.0" Lua constant equals # _extension.yml:3 version AND used in BOTH dependency declaration and # emitted libs URL string. # 5b. Key-only page (issue #164 C14): hermetic render with ONLY a @@ -72,7 +72,7 @@ LUA_FILTER="_extensions/blendtutor/blendtutor.lua" EXTENSION_YML="_extensions/blendtutor/_extension.yml" FIXTURE_DIR="quarto-fixture" MARKER='data-bt-bootstrap="auto"' -LIB_VERSION="0.1.0" +LIB_VERSION="0.2.0" LIBS_REL="libs/quarto-contrib/blendtutor-$LIB_VERSION" if ! command -v quarto &>/dev/null; then @@ -295,7 +295,7 @@ else # "Relative references must start with /, ./, or ../" at import time — # rodney clause 11 caught this; tolerates bare paths, modules # do not). - if has_token "$MIXED_BOOTSTRAP" 'from "./mixed-lang_files/libs/quarto-contrib/blendtutor-0.1.0/exercise-runtime.js"'; then + if has_token "$MIXED_BOOTSTRAP" 'from "./mixed-lang_files/libs/quarto-contrib/blendtutor-0.2.0/exercise-runtime.js"'; then ok "runtime specifier is ES-module-safe (./ prefix)" else ko "runtime specifier is ES-module-safe — missing ./ prefix" @@ -718,10 +718,10 @@ rm -rf "$TMP_LATEX" echo "== Clause 10: BT_DEP_VERSION single-sourced ==" -if has_token "$LUA_CONTENT" 'BT_DEP_VERSION = "0.1.0"'; then - ok "BT_DEP_VERSION = \"0.1.0\" constant in blendtutor.lua" +if has_token "$LUA_CONTENT" 'BT_DEP_VERSION = "0.2.0"'; then + ok "BT_DEP_VERSION = \"0.2.0\" constant in blendtutor.lua" else - ko "BT_DEP_VERSION = \"0.1.0\" — constant missing or drifted" + ko "BT_DEP_VERSION = \"0.2.0\" — constant missing or drifted" fi if has_token "$LUA_CONTENT" 'version = BT_DEP_VERSION'; then @@ -736,10 +736,10 @@ else ko "emitted libs URL uses BT_DEP_VERSION — URL not tied to constant" fi -if [ "$(sed -n '3p' "$EXTENSION_YML" | tr -d ' ')" = "version:0.1.0" ]; then - ok "_extension.yml line 3 version = 0.1.0 (parity with BT_DEP_VERSION)" +if [ "$(sed -n '3p' "$EXTENSION_YML" | tr -d ' ')" = "version:0.2.0" ]; then + ok "_extension.yml line 3 version = 0.2.0 (parity with BT_DEP_VERSION)" else - ko "_extension.yml line 3 version = 0.1.0 — got: $(sed -n '3p' "$EXTENSION_YML")" + ko "_extension.yml line 3 version = 0.2.0 — got: $(sed -n '3p' "$EXTENSION_YML")" fi # --------------------------------------------------------------------------- diff --git a/scripts/tests/test_quarto_bootstrap.sh b/scripts/tests/test_quarto_bootstrap.sh index dd1bfeb..b5b15de 100644 --- a/scripts/tests/test_quarto_bootstrap.sh +++ b/scripts/tests/test_quarto_bootstrap.sh @@ -18,7 +18,7 @@ # NOT double-start-guard reliance. # 5. Non-HTML gate: filter.qmd → latex → zero data-bt-bootstrap="auto". # 6. Libs-URL specifiers + coi depth (AC-4 rewrite): bootstrap import -# specifiers reference _files/libs/quarto-contrib/blendtutor-0.1.0/ +# specifiers reference _files/libs/quarto-contrib/blendtutor-0.2.0/ # (computed from quarto.doc.output_file), never _extensions/ source-tree # paths; coi-book/chapter-coi.qmd shows the coi-serviceworker.js src STILL # depth-correct ../.. _extensions/ (COI stays include_text — SW scope). @@ -432,11 +432,11 @@ fi echo "== Clause 6: libs-URL specifiers + coi depth ==" if [ -f "$MIXED_HTML" ]; then - LIBS_PREFIX='mixed-lang_files/libs/quarto-contrib/blendtutor-0.1.0' + LIBS_PREFIX='mixed-lang_files/libs/quarto-contrib/blendtutor-0.2.0' if has_token "$MIXED_BOOTSTRAP" "$LIBS_PREFIX/exercise-runtime.js" \ && has_token "$MIXED_BOOTSTRAP" "$LIBS_PREFIX/webr-adapter.js" \ && has_token "$MIXED_BOOTSTRAP" "$LIBS_PREFIX/pyodide-adapter.js"; then - ok "specifiers are libs URLs (mixed-lang_files/libs/quarto-contrib/blendtutor-0.1.0/)" + ok "specifiers are libs URLs (mixed-lang_files/libs/quarto-contrib/blendtutor-0.2.0/)" else ko "specifiers are libs URLs — $LIBS_PREFIX/ not found in bootstrap" fi @@ -487,12 +487,12 @@ fi echo "== Clause 10: bootstrap imports mountAllFeedback + mountKeyPage (C5/C6) ==" if [ -f "$MIXED_HTML" ]; then - if has_token "$MIXED_BOOTSTRAP" 'import { mountAllFeedback } from "./mixed-lang_files/libs/quarto-contrib/blendtutor-0.1.0/exercise-feedback.js"'; then + if has_token "$MIXED_BOOTSTRAP" 'import { mountAllFeedback } from "./mixed-lang_files/libs/quarto-contrib/blendtutor-0.2.0/exercise-feedback.js"'; then ok "bootstrap imports mountAllFeedback from libs exercise-feedback.js" else ko "bootstrap imports mountAllFeedback from libs exercise-feedback.js — not found" fi - if has_token "$MIXED_BOOTSTRAP" 'import { mountKeyPage } from "./mixed-lang_files/libs/quarto-contrib/blendtutor-0.1.0/key-page.js"'; then + if has_token "$MIXED_BOOTSTRAP" 'import { mountKeyPage } from "./mixed-lang_files/libs/quarto-contrib/blendtutor-0.2.0/key-page.js"'; then ok "bootstrap imports mountKeyPage from libs key-page.js" else ko "bootstrap imports mountKeyPage from libs key-page.js — not found" diff --git a/scripts/tests/test_quarto_distribution.sh b/scripts/tests/test_quarto_distribution.sh index c68ad9a..e00eca1 100644 --- a/scripts/tests/test_quarto_distribution.sh +++ b/scripts/tests/test_quarto_distribution.sh @@ -306,7 +306,7 @@ fi # Clause 12: no stale mechanism/bootstrap instructions + command/version # consistency (matches ci.yml:148 full org/repo command, _extension.yml:3 -# version 0.1.0, no short-form install). +# version 0.2.0, no short-form install). echo "== Clause 12: no stale mechanism, command/version consistency ==" if [ -f "$README" ] && ! grep -qiE 'relative to.*filter|PANDOC_SCRIPT_FILE|locates its assets' "$README"; then ok "stale mechanism claim removed (relative to filter script / PANDOC_SCRIPT_FILE)" @@ -323,10 +323,10 @@ if [ -f "$README" ] && ! grep -qE 'quarto add[[:space:]]+blendtutor([^/]|$)' "$R else ko "short-form install command found ('quarto add blendtutor')" fi -if [ -f "$README" ] && grep -qF '0.1.0' "$README"; then - ok "version stated matches _extension.yml (0.1.0)" +if [ -f "$README" ] && grep -qF '0.2.0' "$README"; then + ok "version stated matches _extension.yml (0.2.0)" else - ko "version consistency — '0.1.0' not stated in README" + ko "version consistency — '0.2.0' not stated in README" fi # Clause 13: demo book serve-over-HTTP instructions (fix-demo-visible-exercises @@ -514,7 +514,7 @@ fi # never _files/; NO _extensions/ substring in filter-injected bootstrap # specifiers for the four assets. echo "== AC-5 Clause 5: book libs URLs (site_libs) + no _extensions/ in bootstrap ==" -BOOK_LIBS="site_libs/quarto-contrib/blendtutor-0.1.0" +BOOK_LIBS="site_libs/quarto-contrib/blendtutor-0.2.0" if [ -d "$RENDER_HTML_DIR" ]; then # Extract the filter-injected bootstrap body (coi shim src legitimately # contains _extensions/... outside the bootstrap, so scope the check). @@ -569,7 +569,7 @@ else fi # Issue #143 AC-5 clause 6 (amended): files on disk under the shared -# _output/site_libs/quarto-contrib/blendtutor-0.1.0/ — exercise-runtime.js + +# _output/site_libs/quarto-contrib/blendtutor-0.2.0/ — exercise-runtime.js + # styles.css + codemirror.js always; webr-adapter.js iff any R exercise in the # book; pyodide-adapter.js iff any python exercise. Base dir is # demo-book/_output/ (rendered-document-relative), NOT demo-book/. @@ -601,7 +601,7 @@ fi # Issue #143 AC-5 clause 7: COI boundary — r-exercises.html (coi: true) # still loads coi-serviceworker.js via include_text; the shim is NEVER -# deployed via our add_html_dependency into the blendtutor-0.1.0 libs dir +# deployed via our add_html_dependency into the blendtutor-0.2.0 libs dir # (SW scope = script URL dir). NOTE: in BOOK mode Quarto rewrites in-header # src pointing at the by-name extension into its own # site_libs/quarto-contrib/quarto-project/... copy — the shim src is @@ -615,9 +615,9 @@ if [ -f "$RENDER_HTML_DIR/r-exercises.html" ]; then ko "r-exercises coi shim — no coi-serviceworker.js src found" fi if grep -qE 'src="[^"]*blendtutor-0\.1\.0/coi-serviceworker\.js"' "$RENDER_HTML_DIR/r-exercises.html"; then - ko "coi shim NOT in blendtutor libs URL — src points into blendtutor-0.1.0/ dir" + ko "coi shim NOT in blendtutor libs URL — src points into blendtutor-0.2.0/ dir" else - ok "coi shim src does not point into blendtutor-0.1.0 libs dir" + ok "coi shim src does not point into blendtutor-0.2.0 libs dir" fi if [ -d "$BT_SITE_LIBS" ] && [ -f "$BT_SITE_LIBS/coi-serviceworker.js" ]; then ko "coi-serviceworker.js NOT in blendtutor libs dir — found (SW scope break)" diff --git a/scripts/tests/test_quarto_install_render.sh b/scripts/tests/test_quarto_install_render.sh index 27273cb..282ca6a 100644 --- a/scripts/tests/test_quarto_install_render.sh +++ b/scripts/tests/test_quarto_install_render.sh @@ -19,9 +19,9 @@ # quarto render --to html exits 0 # P5 Filter ran: output HTML contains bt-exercise # P6 Asset resolved, not exit-0 alone: HTML references -# index_files/libs/quarto-contrib/blendtutor-0.1.0/styles.css (deployed +# index_files/libs/quarto-contrib/blendtutor-0.2.0/styles.css (deployed # via add_html_dependency under AC-4) AND -# test -f "$TMP/index_files/libs/quarto-contrib/blendtutor-0.1.0/styles.css" +# test -f "$TMP/index_files/libs/quarto-contrib/blendtutor-0.2.0/styles.css" # P7 No old-path leak: HTML does NOT contain _extensions/blendtutor/assets # (non-org prefix) # P8 COI path covered: minimal .qmd div sets coi="true"; HTML references @@ -258,8 +258,8 @@ QMD # P6 — styles.css deployed to the libs dir AND the file exists # (exit-0 alone is insufficient: Quarto does not validate emitted hrefs; # add_html_dependency can silently skip a missing file — assert on disk). - CSS_REF='index_files/libs/quarto-contrib/blendtutor-0.1.0/styles.css' - CSS_FILE="$TMP_DIR/index_files/libs/quarto-contrib/blendtutor-0.1.0/styles.css" + CSS_REF='index_files/libs/quarto-contrib/blendtutor-0.2.0/styles.css' + CSS_FILE="$TMP_DIR/index_files/libs/quarto-contrib/blendtutor-0.2.0/styles.css" if grep -qF "$CSS_REF" <<< "$HTML_CONTENT"; then if [ -f "$CSS_FILE" ]; then ok "P6: styles.css resolved — HTML references $CSS_REF and file exists" diff --git a/scripts/tests/test_quarto_ux.py b/scripts/tests/test_quarto_ux.py index b0673f7..86a8c7a 100644 --- a/scripts/tests/test_quarto_ux.py +++ b/scripts/tests/test_quarto_ux.py @@ -434,7 +434,7 @@ def check_styles_css_loaded() -> None: # AC-4: styles.css deploys via add_html_dependency to the libs dir. ux.qmd # lives in quarto-fixture/ so the emitted href is the document-relative # ux_files/libs/... shape (never _extensions/.../assets/styles.css). - if 'href="ux_files/libs/quarto-contrib/blendtutor-0.1.0/styles.css"' in html: + if 'href="ux_files/libs/quarto-contrib/blendtutor-0.2.0/styles.css"' in html: ok("styles.css present in rendered HTML (libs-dir href)") else: ko("styles.css present in rendered HTML — not found in rendered output") @@ -581,7 +581,7 @@ def check_installed_layout_asset_path() -> None: if tmp is None: return html = (tmp / "test.html").read_text() - if 'href="test_files/libs/quarto-contrib/blendtutor-0.1.0/styles.css"' in html: + if 'href="test_files/libs/quarto-contrib/blendtutor-0.2.0/styles.css"' in html: ok("installed layout — href uses libs-dir deployment (test_files/libs/...)") else: ko("installed layout — href missing libs-dir deployment") @@ -609,7 +609,7 @@ def check_by_name_install_absolute_path() -> None: ko("by-name install — no styles.css href found in rendered HTML") return url = match.group(1) - if url == "test_files/libs/quarto-contrib/blendtutor-0.1.0/styles.css": + if url == "test_files/libs/quarto-contrib/blendtutor-0.2.0/styles.css": ok("by-name install — absolute PANDOC_SCRIPT_FILE handled; libs-dir href deployed") else: ko(f"by-name install — expected libs-dir href, got: {url}") From fdfd117953a0e313c3c5be513103204771ca545d Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 15:28:46 -0600 Subject: [PATCH 25/27] docs: rodney evidence for the Quarto widget display fix Headless Chrome screenshot of an exported R pseudocode lesson rendered with the branch extension: prompt visible, no idle badge or empty output, light widget on a light page. Evidence for the PR; remove before release housekeeping if not wanted on main. --- .../widget-after.png | Bin 0 -> 20885 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/evidence/fix-export-quarto-book-ready/widget-after.png diff --git a/docs/evidence/fix-export-quarto-book-ready/widget-after.png b/docs/evidence/fix-export-quarto-book-ready/widget-after.png new file mode 100644 index 0000000000000000000000000000000000000000..dbd664bee65d9439f5273c8805d3a21772a2bd6b GIT binary patch literal 20885 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 literal 0 HcmV?d00001 From 7728a8ae2dde1b4796977497d54c3cd8c0bc6868 Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 15:31:52 -0600 Subject: [PATCH 26/27] fix: distribution test matches the 0.2.0 libs path in its regex pins The version bump replaced literal 0.1.0 strings but missed two regex-escaped blendtutor-0\.1\.0 patterns (asset href file check, COI shim src check), so the rendered demo book appeared to have no blendtutor asset hrefs. --- scripts/tests/test_quarto_distribution.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/tests/test_quarto_distribution.sh b/scripts/tests/test_quarto_distribution.sh index e00eca1..02e3b5d 100644 --- a/scripts/tests/test_quarto_distribution.sh +++ b/scripts/tests/test_quarto_distribution.sh @@ -476,7 +476,7 @@ else ko "asset href target missing: $href" HREF_MISSING=1 fi - done < <(grep -hoE '(href|src)="[^"]*blendtutor-0\.1\.0/[^"]*"' "$RENDER_HTML_DIR"/*.html 2>/dev/null | sed -E 's/^[^"]*"([^"]*)"/\1/' | sort -u) + done < <(grep -hoE '(href|src)="[^"]*blendtutor-0\.2\.0/[^"]*"' "$RENDER_HTML_DIR"/*.html 2>/dev/null | sed -E 's/^[^"]*"([^"]*)"/\1/' | sort -u) if [ "$HREF_FOUND" -eq 0 ]; then ko "asset href file check — no blendtutor asset hrefs found in rendered HTML" fi @@ -614,7 +614,7 @@ if [ -f "$RENDER_HTML_DIR/r-exercises.html" ]; then else ko "r-exercises coi shim — no coi-serviceworker.js src found" fi - if grep -qE 'src="[^"]*blendtutor-0\.1\.0/coi-serviceworker\.js"' "$RENDER_HTML_DIR/r-exercises.html"; then + if grep -qE 'src="[^"]*blendtutor-0\.2\.0/coi-serviceworker\.js"' "$RENDER_HTML_DIR/r-exercises.html"; then ko "coi shim NOT in blendtutor libs URL — src points into blendtutor-0.2.0/ dir" else ok "coi shim src does not point into blendtutor-0.2.0 libs dir" From 9aacd3cb167f8c2245db3201894e82ee0018c230 Mon Sep 17 00:00:00 2001 From: Michael Mullarkey Date: Sun, 13 Sep 2026 15:39:50 -0600 Subject: [PATCH 27/27] docs: remove PR evidence screenshot before merge The PR body links the screenshot at commit fdfd117, so the image stays viewable; the file does not belong on main. --- .../widget-after.png | Bin 20885 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 docs/evidence/fix-export-quarto-book-ready/widget-after.png diff --git a/docs/evidence/fix-export-quarto-book-ready/widget-after.png b/docs/evidence/fix-export-quarto-book-ready/widget-after.png deleted file mode 100644 index dbd664bee65d9439f5273c8805d3a21772a2bd6b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20885 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