diff --git a/CHANGELOG.md b/CHANGELOG.md
index 069fcf4b6..6c99ed0a8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- Per-lecture live compute: an `enable_live_compute` site option, set `false`
+ under `site:` in a lecture's frontmatter (or notebook metadata) to withhold
+ the in-page compute control on lectures Pyodide cannot run, or site-wide to
+ make the default opt-in. Page value over site value over on, so projects
+ without the flag change nothing. The gate is broad: a gated page loses the
+ toolbar toggle, the execute scope and the error tray together
+ ([#114](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/114)).
+
## [2.7.0] - 2026-09-11
### Added
diff --git a/README.md b/README.md
index 64843dfcf..9befd507c 100644
--- a/README.md
+++ b/README.md
@@ -86,8 +86,8 @@ behaviour above, so existing projects need no changes:
In addition to launching a notebook elsewhere (Colab/Hub), the theme can run
notebook cells **in place** via [Thebe](https://thebe.readthedocs.io). This is
opt-in per project through the standard MyST `thebe` config, set under
-`project.thebe` in `myst.yml` (the theme reads it from the project manifest, so
-it is project-level — not per-page frontmatter). The QuantEcon default is
+`project.thebe` in `myst.yml` (the theme reads it from the project manifest;
+per-lecture gating is the separate `enable_live_compute` option, [below](#per-lecture-live-compute)). The QuantEcon default is
**JupyterLite** — Python runs entirely in the browser via Pyodide, with no
server or Binder to host:
@@ -112,6 +112,33 @@ stack is Pyodide-compatible. Other backends are available through the same
`thebe` config (`binder:` for BinderHub, `server:` for a hosted Jupyter
server) if a project needs a full environment.
+#### Per-lecture live compute
+
+Because Pyodide cannot run every lecture, the `enable_live_compute` site option gates
+the control per page. Mark a lecture that will not run under the configured
+kernel in its frontmatter:
+
+```yaml
+---
+site:
+ enable_live_compute: false
+---
+```
+
+(for a notebook, `"site": {"enable_live_compute": false}` in the notebook metadata).
+Resolution is page value, then the site-wide `site.options.enable_live_compute`, then
+on: with no flag anywhere the control appears wherever `project.thebe` is set,
+so existing projects change nothing and a series adopts the flag by marking its
+known-incompatible lectures `false`. A series that would rather certify one
+lecture at a time sets `enable_live_compute: false` site-wide and opts pages in with
+`true`. The gate is broad: a gated page loses the toolbar toggle, the execute
+scope and the error tray together, so nothing on it tries to run.
+
+The flag should come from testing rather than guesswork: run each lecture's
+cells under the Pyodide kernel and record pass/fail per lecture, then write
+the flags from the record; re-run when a lecture's package usage changes. That
+certification job lives in the lecture repo, not in the theme.
+
### Git history in page headers
The page header shows a "Last changed: ⟨date⟩" control (aligned to the right of
@@ -243,6 +270,7 @@ block inside a string (`key: |`), which the theme parses.
| `favicon` | site | Favicon file, relative to `myst.yml`; served at `/favicon.ico` (the QuantEcon lectures favicon when unset) |
| `analytics_google`, `analytics_plausible` | site | Analytics IDs, rendered by `@myst-theme/site` |
| `hide_toc`, `hide_search` | site or page | Hide the contents drawer / the search control |
+| `enable_live_compute` | site or page | Offer in-page live compute on a page ([Per-lecture live compute](#per-lecture-live-compute)) |
| `launch_repo_url`, `launch_repo_suffix`, `launch_branch`, `launch_notebooks_path`, `launch_source_path` | site | Notebook launcher conventions ([Launch buttons](#launch-buttons)) |
| `current_language`, `enable_rtl`, `languages`, `language_switcher_label` | site | Multilingual editions ([below](#multilingual-editions)) |
| `translators`, `translators_label` | site or page | Translator credit in the page header |
diff --git a/app/components/Page.tsx b/app/components/Page.tsx
index ccc1e4d24..aefe0c9d1 100644
--- a/app/components/Page.tsx
+++ b/app/components/Page.tsx
@@ -1,10 +1,12 @@
import type { PageLoader } from '@myst-theme/common';
import { useLoaderData } from '@remix-run/react';
+import { useCallback } from 'react';
import type { SiteManifest } from 'myst-config';
import { useBaseurl, useSiteManifest, ProjectProvider } from '@myst-theme/providers';
import { ComputeOptionsProvider, ThebeLoaderAndServer } from '@myst-theme/jupyter';
import { PageContent } from '~/components/PageContent';
import type { TemplateOptions } from '~/types';
+import { resolveLiveCompute } from '~/liveCompute';
import { NavigationAndArticleWrapper } from './NavigationAndArticleWrapper';
import { PageProvider } from './PageProvider';
@@ -23,12 +25,24 @@ export function Page() {
...siteDesign,
...pageDesign,
};
+ // Per-lecture live compute (#114). Returning `undefined` from the override
+ // makes ComputeOptionsProvider report `enabled: false`, which takes the
+ // toolbar toggle, the error tray and the execute scope down together -- the
+ // broad gate, since "not compatible" means nothing on the page should run.
+ // Memoised: the provider recomputes its options whenever this identity
+ // changes.
+ const liveCompute = resolveLiveCompute(pageDesign as any, siteDesign as any);
+ const gateCompute = useCallback(
+ (options: any) => (liveCompute ? options : undefined),
+ [liveCompute]
+ );
return (
| undefined,
+ siteOptions: Record | undefined,
+): boolean {
+ return asBoolean(pageOptions?.enable_live_compute) ?? asBoolean(siteOptions?.enable_live_compute) ?? true;
+}
diff --git a/app/types.ts b/app/types.ts
index d0226061e..8a1b439f5 100644
--- a/app/types.ts
+++ b/app/types.ts
@@ -9,6 +9,9 @@ export interface TemplateOptions {
// the site-wide options.
hide_toc?: boolean;
hide_search?: boolean;
+ // Per-lecture live compute (#114): gates the whole in-page compute surface
+ // on this page. Page value over site value over `true`; see app/liveCompute.ts.
+ enable_live_compute?: boolean;
// Meta / SEO and analytics, passed through to @myst-theme/site.
twitter?: string; // handle for twitter:site / twitter:creator, `@` optional
diff --git a/docs/configuration.md b/docs/configuration.md
index a3d1222d5..0a631f6d6 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -25,6 +25,7 @@ below.
| `analytics_plausible` | string | site | — | Plausible domain |
| `hide_toc` | boolean | site or page | `false` | hide the contents drawer and its toggle |
| `hide_search` | boolean | site or page | `false` | hide the search control |
+| `enable_live_compute` | boolean | site or page | `true` | offer in-page live compute on a page ([notebooks](notebooks.md#per-lecture-live-compute)) |
| `launch_repo_url` | string | site | derived | explicit notebook repository |
| `launch_repo_suffix` | string | site | `.notebooks` | suffix locating the notebook repo |
| `launch_branch` | string | site | `main` | notebook repo branch |
diff --git a/docs/notebooks.md b/docs/notebooks.md
index 489eb321e..be9fe6db7 100644
--- a/docs/notebooks.md
+++ b/docs/notebooks.md
@@ -34,3 +34,27 @@ projects that need a full environment.
The deployed Sphinx lecture sites set `thebe: false`, so a series moving from
them changes nothing by leaving `project.thebe` unset.
+
+### Per-lecture live compute
+
+Because Pyodide cannot run every lecture, the `enable_live_compute` site
+option gates the control per page. A lecture that will not run under the
+configured kernel sets it under `site:` in its frontmatter (for a notebook,
+`"site": {"enable_live_compute": false}` in the notebook metadata):
+
+```yaml
+---
+site:
+ enable_live_compute: false
+---
+```
+
+Resolution is page value, then the site-wide `site.options.enable_live_compute`,
+then on: with no flag anywhere the control appears wherever `project.thebe` is
+set, so a series adopts the flag by marking its known-incompatible lectures
+`false`. A series that would rather certify one lecture at a time sets
+`enable_live_compute: false` site-wide and opts pages in with `true`. The gate
+is broad: a gated page loses the toolbar toggle, the execute scope and the
+error tray together, so nothing on it tries to run. The flag should come from
+running each lecture's cells under the kernel and recording pass/fail, a job
+that lives in the lecture repo rather than the theme.
diff --git a/template.yml b/template.yml
index 693773989..b6cd72ac2 100644
--- a/template.yml
+++ b/template.yml
@@ -103,6 +103,14 @@ options:
- id: hide_search
type: boolean
description: Hide the toolbar search control.
+ - id: enable_live_compute
+ type: boolean
+ description: >
+ Whether in-page live compute (`project.thebe`) is offered on a page. Set
+ `false` under `site:` in a lecture's frontmatter when it cannot run under
+ the configured kernel (numba, JAX and other packages Pyodide lacks); set
+ it site-wide to change the default for every page. Unset means on
+ wherever `project.thebe` is configured.
# --- Notebook launcher (see README "Launch buttons") ---
- id: launch_repo_url
type: string
diff --git a/tests/unit/live-compute.test.mjs b/tests/unit/live-compute.test.mjs
new file mode 100644
index 000000000..81f7cd4f2
--- /dev/null
+++ b/tests/unit/live-compute.test.mjs
@@ -0,0 +1,37 @@
+/**
+ * Unit tests for the per-lecture live-compute resolver (app/liveCompute.ts,
+ * #114): page value over site value over an enabled default. Run with
+ * `npm run test:unit` (node --test with type stripping, Node >= 23.6).
+ */
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { resolveLiveCompute } from '../../app/liveCompute.ts';
+
+test('absent everywhere: enabled (today\'s behaviour, project.thebe decides)', () => {
+ assert.equal(resolveLiveCompute(undefined, undefined), true);
+ assert.equal(resolveLiveCompute({}, {}), true);
+});
+
+test('site-wide false turns the default off; a page opts back in', () => {
+ assert.equal(resolveLiveCompute({}, { enable_live_compute: false }), false);
+ assert.equal(resolveLiveCompute({ enable_live_compute: true }, { enable_live_compute: false }), true);
+});
+
+test('page false wins over an enabled site (incremental adoption)', () => {
+ assert.equal(resolveLiveCompute({ enable_live_compute: false }, {}), false);
+ assert.equal(resolveLiveCompute({ enable_live_compute: false }, { enable_live_compute: true }), false);
+});
+
+test('a page that sets other site keys but not this one inherits the site value', () => {
+ assert.equal(resolveLiveCompute({ hide_search: true }, { enable_live_compute: false }), false);
+ assert.equal(resolveLiveCompute({ hide_search: true }, {}), true);
+});
+
+test('string spellings from hand edits are read; garbage falls through', () => {
+ assert.equal(resolveLiveCompute({ enable_live_compute: 'false' }, {}), false);
+ assert.equal(resolveLiveCompute({ enable_live_compute: 'no' }, {}), false);
+ assert.equal(resolveLiveCompute({ enable_live_compute: 'TRUE' }, { enable_live_compute: false }), true);
+ assert.equal(resolveLiveCompute({ enable_live_compute: 'maybe' }, { enable_live_compute: false }), false);
+ assert.equal(resolveLiveCompute({ enable_live_compute: 'maybe' }, {}), true);
+});
diff --git a/tests/visual/README.md b/tests/visual/README.md
index c62b462bc..5ddcff46c 100644
--- a/tests/visual/README.md
+++ b/tests/visual/README.md
@@ -116,7 +116,10 @@ THEME_TEMPLATE="$PWD/.deploy/quantecon-theme" \
- `fixture-no-thebe/` — the same without `project.thebe`, for the absent live-compute
toggle (second port)
- `fixture-rtl/` — a Persian edition with `enable_rtl` (third port), for the `rtl`
- snapshot and the right-to-left assertions (#91)
+ snapshot and the right-to-left assertions (#91); also carries the per-lecture
+ live-compute cases (#114) — `project.thebe` on, `enable_live_compute: false` site-wide,
+ `notebook.ipynb` inheriting it and `notebook-live.ipynb` opting back in — because
+ its only snapshot is the landing page, so the extra pages move no baseline
- `fixture/myst.yml.in` — template; `serve.sh` writes `myst.yml` from it
- `serve.sh` — `myst start` with the chosen `THEME_TEMPLATE`
- `serve-static.sh` / `static-server.mjs` — `myst build --html` of the fixture behind
diff --git a/tests/visual/fixture-rtl/myst.yml.in b/tests/visual/fixture-rtl/myst.yml.in
index 99f4c5f82..f8a61c647 100644
--- a/tests/visual/fixture-rtl/myst.yml.in
+++ b/tests/visual/fixture-rtl/myst.yml.in
@@ -10,12 +10,23 @@ project:
# theme's parenthesised markers in RTL whichever mystmd parses it.
plugins:
- ../fixture/fancy-lists.mjs
+ # Live compute is on for the project, but the site-wide `enable_live_compute: false`
+ # below turns it off by default; notebook-live.ipynb opts back in per page.
+ # This fixture carries the per-lecture gate tests (#114) because its only
+ # snapshot is the landing page, so adding notebook pages moves no baseline.
+ thebe:
+ lite: true
toc:
- file: intro.md
+ - file: notebook.ipynb
+ - file: notebook-live.ipynb
site:
title: QE Theme RTL Fixture
template: __THEME__
options:
+ # Per-lecture live compute (#114): off by default for this project; a
+ # page opts in with `site: {enable_live_compute: true}` (notebook-live.ipynb).
+ enable_live_compute: false
current_language: fa
enable_rtl: true
language_switcher_label: تغییر زبان
diff --git a/tests/visual/fixture-rtl/notebook-live.ipynb b/tests/visual/fixture-rtl/notebook-live.ipynb
new file mode 100644
index 000000000..d9d4ec649
--- /dev/null
+++ b/tests/visual/fixture-rtl/notebook-live.ipynb
@@ -0,0 +1,84 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": "# Notebook opted into compute\n\nThis page exercises **notebook cell output rendering** — the area changed by the\n`@myst-theme` v1.0.0 output-node AST change. Outputs are baked in (no execution).\n\nThis page sets `site: {enable_live_compute: true}` in its notebook metadata, overriding the site-wide `false` (#114).\n"
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "hello from a stream output\n"
+ ]
+ }
+ ],
+ "source": [
+ "print(\"hello from a stream output\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ " value\n",
+ "count 3.0\n",
+ "mean 2.0\n",
+ "std 1.0"
+ ]
+ },
+ "execution_count": 2,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "summary # a plain-text execute_result"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {},
+ "outputs": [
+ {
+ "ename": "ValueError",
+ "evalue": "a deliberate error to render the traceback",
+ "output_type": "error",
+ "traceback": [
+ "Traceback (most recent call last):",
+ "ValueError: a deliberate error to render the traceback"
+ ]
+ }
+ ],
+ "source": [
+ "raise ValueError(\"a deliberate error to render the traceback\")"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python",
+ "version": "3.11"
+ },
+ "site": {
+ "enable_live_compute": true
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
\ No newline at end of file
diff --git a/tests/visual/fixture-rtl/notebook.ipynb b/tests/visual/fixture-rtl/notebook.ipynb
new file mode 100644
index 000000000..4179ef3b0
--- /dev/null
+++ b/tests/visual/fixture-rtl/notebook.ipynb
@@ -0,0 +1,81 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": "# Notebook without compute\n\nThis page exercises **notebook cell output rendering** — the area changed by the\n`@myst-theme` v1.0.0 output-node AST change. Outputs are baked in (no execution).\n\nThis project sets `enable_live_compute: false` site-wide and this page does not override it, so no live-compute control renders (#114).\n"
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "hello from a stream output\n"
+ ]
+ }
+ ],
+ "source": [
+ "print(\"hello from a stream output\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ " value\n",
+ "count 3.0\n",
+ "mean 2.0\n",
+ "std 1.0"
+ ]
+ },
+ "execution_count": 2,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "summary # a plain-text execute_result"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {},
+ "outputs": [
+ {
+ "ename": "ValueError",
+ "evalue": "a deliberate error to render the traceback",
+ "output_type": "error",
+ "traceback": [
+ "Traceback (most recent call last):",
+ "ValueError: a deliberate error to render the traceback"
+ ]
+ }
+ ],
+ "source": [
+ "raise ValueError(\"a deliberate error to render the traceback\")"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python",
+ "version": "3.11"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
\ No newline at end of file
diff --git a/tests/visual/theme.spec.ts b/tests/visual/theme.spec.ts
index df57c5893..61de4da96 100644
--- a/tests/visual/theme.spec.ts
+++ b/tests/visual/theme.spec.ts
@@ -323,6 +323,33 @@ test.describe("QuantEcon theme — visual regression", () => {
await expect(page.getByRole("heading", { name: "Notebook outputs" })).toBeVisible();
await expect(page.getByRole("button", { name: /start compute/i })).toHaveCount(0);
});
+
+ // Per-lecture gate (#114): the RTL fixture has `project.thebe` but sets
+ // `enable_live_compute: false` site-wide. Its plain notebook page inherits that and
+ // shows no control; notebook-live.ipynb sets `site: {enable_live_compute: true}`
+ // in its metadata and gets it back. The main fixture's `live-compute-toggle`
+ // above is the third case: no flag anywhere means on.
+ test("live-compute-per-page", async ({ page }, testInfo) => {
+ test.skip(
+ testInfo.project.name !== "desktop-chrome",
+ "the live-compute toggle lives in the desktop header toolbar"
+ );
+ const rtlBase = `http://localhost:${process.env.RTL_PORT}`;
+ await page.goto(`${rtlBase}/notebook`, { waitUntil: "domcontentloaded" });
+ await settle(page);
+ await expect(page.getByRole("heading", { name: "Notebook without compute" })).toBeVisible();
+ await expect(page.getByRole("button", { name: /start compute/i })).toHaveCount(0);
+ // The gate is broad: the compute-enabled error tray is gone too, so
+ // nothing on the page can try to execute.
+ await expect(page.locator("#qe-compute-slot")).toBeEmpty();
+
+ await page.goto(`${rtlBase}/notebook-live`, { waitUntil: "domcontentloaded" });
+ await settle(page);
+ await expect(page.getByRole("heading", { name: "Notebook opted into compute" })).toBeVisible();
+ await expect(
+ page.locator("#qe-compute-slot").getByRole("button", { name: /start compute/i })
+ ).toBeVisible();
+ });
});
/**