Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c6726f4
test(red): export-quarto carries gotchas and packages to the Quarto div
mcmullarkey Sep 13, 2026
ce7094b
feat: export-quarto renders gotchas div and packages attribute
mcmullarkey Sep 13, 2026
e3974ce
fix(review): reject package names that break comma-separated lists
mcmullarkey Sep 13, 2026
cc3aefc
test(red): export-quarto --document and --key-page produce renderable…
mcmullarkey Sep 13, 2026
4f99486
docs: ADR-0019 export-quarto document shape and key-page export
mcmullarkey Sep 13, 2026
fe76fd4
feat: export-quarto --document and --key-page print renderable pages
mcmullarkey Sep 13, 2026
f01718e
test(red): export-quarto warns when a lesson has no checks, solution,…
mcmullarkey Sep 13, 2026
4df77c8
feat: export-quarto warns on stderr when a lesson has no learner aids
mcmullarkey Sep 13, 2026
cf1e594
test(red): new lesson scaffolds commented optional fields
mcmullarkey Sep 13, 2026
4f00e04
feat: new lesson templates show commented optional fields
mcmullarkey Sep 13, 2026
6da8eca
test(red): success criteria reach the feedback prompt and the filter …
mcmullarkey Sep 13, 2026
9342cf0
docs: ADR-0020 success criteria reach the LLM feedback prompt
mcmullarkey Sep 13, 2026
5ee20d7
fix: export_lesson_to_qmd docs no longer link a private item
mcmullarkey Sep 13, 2026
511f67b
feat: success criteria reach the LLM feedback prompt in CLI, site, an…
mcmullarkey Sep 13, 2026
b57799b
fix(review): parse_inner_blocks @return doc lists success_criteria
mcmullarkey Sep 13, 2026
8f77576
docs: update Quarto export, install location, and learner-aid authoring
mcmullarkey Sep 13, 2026
fafd782
test(red): demo docs must say R runs in book projects without COI
mcmullarkey Sep 13, 2026
37d46ac
fix: docs and export comment say R runs in book projects without COI
mcmullarkey Sep 13, 2026
cef7ae5
test(red): Quarto widget keeps its prompt, hides idle chrome, labels …
mcmullarkey Sep 13, 2026
9f895da
fix(review): display test compares tokens case-insensitively and anch…
mcmullarkey Sep 13, 2026
84bec12
docs: ADR-0021 Quarto widget follows the page theme and hides idle ch…
mcmullarkey Sep 13, 2026
c089b95
feat: Quarto widget keeps its prompt, hides idle chrome, labels the k…
mcmullarkey Sep 13, 2026
0e7d7f8
fix(review): style the kept prompt and surface errors from a throwing…
mcmullarkey Sep 13, 2026
009ae10
Merge remote-tracking branch 'origin/main' into fix/export-quarto-boo…
mcmullarkey Sep 13, 2026
7cfdc91
chore: bump version to 0.2.0
mcmullarkey Sep 13, 2026
fdfd117
docs: rodney evidence for the Quarto widget display fix
mcmullarkey Sep 13, 2026
7728a8a
fix: distribution test matches the 0.2.0 libs path in its regex pins
mcmullarkey Sep 13, 2026
9aacd3c
docs: remove PR evidence screenshot before merge
mcmullarkey Sep 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
34 changes: 17 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand All @@ -103,29 +108,24 @@ 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

A complete demo book with R and Python exercises lives in
[`demo-book/`](demo-book/), rendered live at
<https://mcmullarkey.github.io/blendtutor/demo-book/> (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
Expand Down
2 changes: 1 addition & 1 deletion _extensions/blendtutor/_extension.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
title: blendtutor
author: blendtutor
version: 0.1.0
version: 0.2.0
contributes:
filters:
- blendtutor.lua
9 changes: 8 additions & 1 deletion _extensions/blendtutor/assets/exercise-feedback.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 : [],
Expand Down
29 changes: 26 additions & 3 deletions _extensions/blendtutor/assets/exercise-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/**
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand Down
20 changes: 19 additions & 1 deletion _extensions/blendtutor/assets/key-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
58 changes: 58 additions & 0 deletions _extensions/blendtutor/assets/quarto-theme.css
Original file line number Diff line number Diff line change
@@ -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;
}
20 changes: 20 additions & 0 deletions _extensions/blendtutor/assets/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading