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 3f54aae..0ccd3b3 100644
--- a/README.md
+++ b/README.md
@@ -67,9 +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.
+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.
#### Quick start (zero hand-written bootstrap)
@@ -94,6 +94,11 @@ 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"`.
+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
The filter auto-bootstraps by default; to wire up the runtime yourself, set
@@ -103,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
@@ -118,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/_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/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/assets/exercise-runtime.js b/_extensions/blendtutor/assets/exercise-runtime.js
index bb94269..42f2007 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,8 +427,17 @@ 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";
+ } 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/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..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);
@@ -656,6 +666,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 626716b..1c4f788 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-).
@@ -169,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
@@ -198,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
@@ -243,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
@@ -383,9 +384,11 @@ 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
+-- @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
@@ -393,6 +396,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 +419,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 +434,7 @@ local function parse_inner_blocks(blocks)
solution = solution,
hints = hints,
gotchas = gotchas,
+ success_criteria = success_criteria,
}
end
@@ -434,7 +442,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 +461,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/cli/src/commands/export_quarto.rs b/crates/cli/src/commands/export_quarto.rs
index f3ec5f7..5709a23 100644
--- a/crates/cli/src/commands/export_quarto.rs
+++ b/crates/cli/src/commands/export_quarto.rs
@@ -1,25 +1,52 @@
-//! `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, thin_lesson_warning,
+};
-/// Load the lesson at `path`, transform it to a `.qmd` snippet, and write it
-/// to stdout.
+/// 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, 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
/// 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 +55,9 @@ pub fn run(path: &Path) -> anyhow::Result {
}
Err(LoadError::Read(error)) => return Err(error.into()),
};
- let qmd = export_lesson_to_qmd(&lesson);
- print!("{qmd}");
+ if let Some(warning) = thin_lesson_warning(&lesson) {
+ eprintln!("{warning}");
+ }
+ 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/cli/tests/export_quarto.rs b/crates/cli/tests/export_quarto.rs
index 595702c..0d45af7 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}"
);
}
@@ -349,3 +373,158 @@ 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 note that book projects run R without COI, 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()
+ );
+}
+
+// ── 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)
+ );
+ }
+}
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();
+ }
+}
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/assets/shared/styles.css b/crates/core/assets/shared/styles.css
index 39c146a..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);
@@ -631,6 +640,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/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/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 c7a3411..a73c118 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
@@ -14,12 +15,76 @@
//! | `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`) |
+//! | `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) |
-//! | `lesson.packages` | OMITTED (out-of-scope per decomposition) |
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: 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 = "\
+# 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`.
+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;
@@ -27,23 +92,39 @@ 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, 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).
+///
+/// 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`]).
+/// * `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.
- 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,12 +173,74 @@ 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");
+ }
+
+ // 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");
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 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 {
+ 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
@@ -155,7 +298,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}"
@@ -165,7 +308,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}"
@@ -175,7 +318,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}"
@@ -187,7 +330,7 @@ exercise:
}
#[test]
- fn export_excludes_gotchas() {
+ fn export_renders_gotchas_as_gotchas_div() {
let yaml = r#"
lesson_name: "Gotchas"
language: R
@@ -198,37 +341,30 @@ exercise:
llm_evaluation_prompt: "Grade this: {student_code}"
"#;
let lesson = Lesson::parse(yaml).unwrap();
- let qmd = export_lesson_to_qmd(&lesson);
- assert!(
- !qmd.contains("gotchas"),
- "gotchas must be absent, got:\n{qmd}"
- );
+ let qmd = export_lesson_to_qmd(&lesson, ExportShape::Snippet);
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}"
"#;
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("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}"
);
}
@@ -242,7 +378,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}"
@@ -255,6 +391,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]
@@ -268,7 +408,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}"
@@ -292,7 +432,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}"
@@ -311,8 +451,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"
);
}
@@ -344,4 +484,92 @@ 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"));
+ }
+
+ #[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);
+ }
+
+ #[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/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
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/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/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/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/assets/exercise-runtime.js b/demo-book/_extensions/mcmullarkey/blendtutor/assets/exercise-runtime.js
index bb94269..42f2007 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,8 +427,17 @@ 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";
+ } 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/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..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);
@@ -656,6 +666,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 626716b..1c4f788 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-).
@@ -169,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
@@ -198,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
@@ -243,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
@@ -383,9 +384,11 @@ 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
+-- @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
@@ -393,6 +396,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 +419,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 +434,7 @@ local function parse_inner_blocks(blocks)
solution = solution,
hints = hints,
gotchas = gotchas,
+ success_criteria = success_criteria,
}
end
@@ -434,7 +442,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 +461,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/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..578d2f8
--- /dev/null
+++ b/docs/adr/0019-export-quarto-document-and-key-page.md
@@ -0,0 +1,66 @@
+# 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: 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
+ 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.
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.
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.
diff --git a/docs/book/src/creating-lessons.md b/docs/book/src/creating-lessons.md
index cb5f66a..1f18db7 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 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).
## Complete example courses
diff --git a/docs/book/src/whole-game.md b/docs/book/src/whole-game.md
index 1e35fe4..085b214 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 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).
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_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
# ---------------------------------------------------------------------------
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_display.py b/scripts/tests/test_quarto_display.py
new file mode 100644
index 0000000..ccf45d6
--- /dev/null
+++ b/scripts/tests/test_quarto_display.py
@@ -0,0 +1,253 @@
+#!/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,
+ 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:
+ """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"(?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 == 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")
+
+
+# ---------------------------------------------------------------------------
+# 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 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",
+ )
+ 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())
diff --git a/scripts/tests/test_quarto_distribution.sh b/scripts/tests/test_quarto_distribution.sh
index c68ad9a..02e3b5d 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
@@ -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
@@ -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
@@ -614,10 +614,10 @@ 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
- ko "coi shim NOT in blendtutor libs URL — src points into blendtutor-0.1.0/ dir"
+ 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.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_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/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}")
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)}")