Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 11 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ jobs:
# The slowest job. A release PR only changes version strings and the
# changelog, so it is skipped there (a skipped job counts as passing for
# required checks).
# It builds the JS bundle itself, so it runs in parallel with the unit tests.
example-course:
if: github.head_ref != 'release-please--branches--main--components--course-kit'
needs: [python, js]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -65,14 +65,21 @@ jobs:
(cd js && npm ci --no-audit --no-fund)
- name: Build the example course
run: tests/scripts/build-example.sh
- uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('python/pyproject.toml', 'python/uv.lock') }}
restore-keys: playwright-${{ runner.os }}-
- name: Install the browser
run: python/.venv/bin/playwright install --with-deps chromium
# Cold Pyodide/JupyterLite downloads make the first run timing-sensitive,
# so failed tests are retried once.
# so failed tests are retried once. The tests share one browser context
# (see tests/e2e/conftest.py); --durations shows where the time goes.
- name: Browser smoke test
run: |
python/.venv/bin/playwright install --with-deps chromium
(cd tests/example-course/_build/html && python3 -m http.server 8765 >/dev/null 2>&1 &)
sleep 2
NMA_E2E_URL=http://localhost:8765 python/.venv/bin/pytest -q --reruns 1 tests/e2e
NMA_E2E_URL=http://localhost:8765 python/.venv/bin/pytest -v --durations=15 --reruns 1 tests/e2e

course-check:
needs: [python, js]
Expand Down
47 changes: 44 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ python/src/nma_kit/
convert.py # content rules, jupytext conversion, badges, prepare-site
media.py solutions.py feedback.py scrolly.py cells.py # Python side of each feature
pin.py # pin course-kit CDN URLs in myst.yml to one version
branding.py # sync-assets: _nma/site.css (+ brand colours, extra CSS), logos, favicon
assets/ # the branding source: site.css, logos, favicon, fonts/ (Rubik, OFL)
publish.py # push notebooks/ to the notebooks branch (orphan branch if missing)
tests/
python/ js/ e2e/ # unit tests (pytest, node:test) and the browser smoke test
Expand Down Expand Up @@ -88,9 +90,38 @@ templates/wheels.yml # optional caller for course-wheels.yml
- **One version everywhere.** `tests/python/test_release.py` checks that
`js/package.json`, `python/pyproject.toml`, `nma_kit.__version__`,
`.release-please-manifest.json` and `KIT_VERSION` in the reusable workflows agree.
- Courses keep one rule in their own CSS (a plugin can't add site-wide CSS):
`.myst-jp-nb-toolbar { display: none !important; }` (editable cells replace
JupyterLite's power button).
- The toolbar rule `.myst-jp-nb-toolbar { display: none !important; }`
(editable cells replace JupyterLite's power button) is in the kit's
`site.css`; courses no longer carry it.

## Site branding (internals)

- **Source of truth**: `python/src/nma_kit/assets/` (`site.css`, logos, favicon,
`fonts/`). It lives inside the Python package, not in a top-level folder,
because `uv build` makes the wheel from the sdist, which can't include files
from outside `python/`. `tests/python/test_branding.py` checks they ship.
- **`nma-kit sync-assets`** (also run by `prepare-site`, not by `--dry-run`)
writes `_nma/` in the course: `site.css`, `logo.svg`, `logo-dark.svg`,
`favicon.png`, and a `.gitignore` containing `*`, so courses never commit it.
- **Courses name the files themselves** in `site.options` (`style`, `logo`,
`logo_dark`, `favicon`). The kit's `nma.yml` does not set them: MyST lets the
course's values win over an extended file's, and how it resolves relative
file paths of a *remote* extended config is unverified. Don't move them into
`nma.yml` without testing that in a real course.
- **One `style` slot**: `site.css` = kit CSS (Rubik embedded as data URIs, so
no relative `url()` depends on where MyST serves the file and the kit's CSS
makes no font requests; the built site still requests Roboto from
`fonts.gstatic.com` from something that isn't ours, see
`tests/e2e/test_branding_browser.py::test_no_google_font_requests`)
+ `:root` overrides from `nma.brand` + the course's
`nma.style.extra` file.
- **CSS variables are the API**: `--nma-brand-*` (the five colours a course may
change), `--nma-cell-*` and `--nma-tok-*` (read by `cells/assets/cells.css`).
Syntax-token colours are fixed on purpose (chosen for contrast).
- Settings are validated by `branding.brand_errors` inside `check`.
- `tests/scripts/build-example.sh` ends with `git checkout -- tutorials myst.yml`
to undo `prepare-site`: stage (`git add`) edits to the example course's
`myst.yml` before running it, or they are reverted too.

## Editable cells (internals)

Expand Down Expand Up @@ -118,6 +149,16 @@ templates/wheels.yml # optional caller for course-wheels.yml
- The section sits between the book theme's TOC sidebar and outline
(`.scrolly-col` in `scrolly.css`) and uses container queries on its own width
(stage beside the cards from 760px, `WIDE` in `engine.mjs`).
- **Colours follow the site.** `scrolly.css` defines `--sc-*` tokens on `.scrolly`
(light) and overrides them under `.dark .scrolly`; `runnable.css` and the HUD
styles in `engine.mjs` read the same tokens (custom properties inherit into
the shadow DOM, so no JS switches modes). Brand colours come from the kit
stylesheet's `--nma-brand-*`, so `nma.brand` recolours scrolly pages too;
syntax colours reuse `--nma-tok-*` (shared with the editable cells). Hex
values in `var(..., #hex)` are the Neuromatch defaults for courses that
don't load `site.css`. `tests/js/scrolly.test.mjs` checks that every
`--sc-*` in use is defined and that dark mode only overrides known tokens.
Don't add fixed colours: add a token.
- Engine (HUD), stage and steps share one grid cell (`grid-area: 1 / 1`), not
negative margins (a sticky element with `margin-bottom: -height` covered the
footer links; regression test in `tests/js/scrolly.test.mjs`).
Expand Down
70 changes: 66 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ in one versioned package:
`{stage}`) and editable, runnable code cells.
- **`nma-kit` CLI**: content checks, notebook generation (student, instructor,
solutions), scrolly pre-rendering, site preparation, notebook publishing.
- **Site branding**: the Neuromatch stylesheet, Rubik font, logos and favicon,
written into a course by `nma-kit sync-assets`.
- **Reusable GitHub workflows**: check pull requests, publish notebooks and the
site to GitHub Pages.

Expand All @@ -27,6 +29,16 @@ which teaches the authoring conventions.
project:
github: https://github.com/<org>/<course>
# ...
site:
template: book-theme
options:
folders: true
logo_text: My Course Title # your course's name next to the logo
# The kit's branding, written to _nma/ by `nma-kit sync-assets`:
style: _nma/site.css
logo: _nma/logo.svg
logo_dark: _nma/logo-dark.svg
favicon: _nma/favicon.png
```

2. Copy [`templates/course.yml`](templates/course.yml) to
Expand All @@ -35,16 +47,21 @@ which teaches the authoring conventions.
the site.
3. In the repo's **Settings → Pages**, set **Source** to **GitHub Actions**
(and the custom domain, if any; no `CNAME` file needed).
4. Keep this rule in your site CSS (a plugin can't add site-wide CSS):
4. Generate the branding files once for local previews (CI does it for you):

```css
.myst-jp-nb-toolbar { display: none !important; }
```bash
uv run nma-kit sync-assets
```

This writes `_nma/` (stylesheet, logos, favicon). The folder ignores itself,
so nothing gets committed. Re-run it after upgrading the kit. See
[Site branding](#site-branding).

5. List the packages your tutorials import in `requirements.txt` (CI installs
them next to the kit; `nma-kit render-scrolly` executes scrolly pages).

Optional settings, in the `nma:` block of `myst.yml` (defaults shown):
Optional settings, in the `nma:` block of `myst.yml` (defaults shown, except
`brand` and `style`, which are unset by default):

```yaml
nma:
Expand All @@ -58,8 +75,52 @@ nma:
user_key: ...
scrolly:
pyodide_url: https://cdn.jsdelivr.net/pyodide/v314.0.7/full/
brand: # colours, see "Site branding"
primary: "#008CBF"
style:
extra: _static/extra.css # your own CSS, appended after the kit's
```

## Site branding

The kit carries the Neuromatch look: the colour palette, the Rubik font
(embedded in the stylesheet, so the kit's own CSS makes no font requests), the
logos and the favicon. It also styles the kit's own widgets (editable cells,
scrollytelling) and hides JupyterLite's toolbar, so courses no longer carry
that rule themselves.

`nma-kit sync-assets` writes these files to `_nma/` in the course:

| File | Used as |
|---|---|
| `_nma/site.css` | `site.options.style`: the kit stylesheet, then your colour overrides, then your own CSS |
| `_nma/logo.svg`, `_nma/logo-dark.svg` | `site.options.logo`, `logo_dark` |
| `_nma/favicon.png` | `site.options.favicon` |

CI runs it as part of `nma-kit prepare-site`. Locally, run it once and again
after upgrading the kit. MyST accepts a single `style` file, which is why the
kit stylesheet and your own CSS are combined into `_nma/site.css`.

**Change the colours** with the `nma.brand` block (all keys optional, each a
`"#RRGGBB"` string):

```yaml
nma:
brand:
primary: "#D9480F" # links, accents, buttons
primary_hover: "#A63700" # default: primary, 25% darker
primary_light: "#FF8A57" # accent on dark backgrounds, default: primary, 35% lighter
secondary: "#45429C" # header surface, tips
accent: "#FA7A7A" # exercise cells
```

**Add your own CSS** with `nma.style.extra`: the file (relative to the course
root) is appended after the kit's CSS, so its rules win. The brand colours are
CSS variables (`--nma-brand-primary`, ...), so your CSS can use them too.

`nma-kit check` reports unknown keys and malformed colours. The course title
and `logo_text` stay in your `myst.yml`.

## Versions and updates

The kit is on **0.x** until it is stable. A breaking change bumps the minor
Expand Down Expand Up @@ -95,6 +156,7 @@ Local previews cache the downloaded plugin for 30 days in `_build/cache`;
```bash
uv venv && uv pip install -r requirements.txt neuromatch-course-kit==0.1.*
uv run nma-kit check
uv run nma-kit sync-assets # site branding, written to _nma/
uv run nma-kit render-scrolly # optional: real outputs on scrolly pages
uv run myst start
```
Expand Down
8 changes: 6 additions & 2 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,12 @@ nma:
pyodide_url: https://cdn.jsdelivr.net/pyodide/v314.0.7/full/
```

One rule belongs in the course's own CSS, because a plugin can't add
site-wide CSS: `.myst-jp-nb-toolbar { display: none !important; }`.
A plugin can't add site-wide CSS, so the Neuromatch look (colours, Rubik font,
logos, favicon, and the rule that hides JupyterLite's toolbar) comes from the
`nma-kit sync-assets` command in the PyPI package instead. It writes `_nma/`,
which the course's `myst.yml` lists under `site.options` (`style`, `logo`,
`logo_dark`, `favicon`). See the
[kit README](https://github.com/neuromatch/course-kit#site-branding).

## What's in the package

Expand Down
3 changes: 2 additions & 1 deletion js/src/cells/assets/cells.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* Editable cell panel (in each panel's shadow root).
Layout: header, editor, action bar, output. Colours follow the site theme.
Public tokens, set on :root / html.dark in _static/custom.css:
Public tokens, set on :root / html.dark in the kit stylesheet (python/src/nma_kit/assets/site.css);
a course overrides them in its own CSS (nma.style.extra):
--nma-cell-{bg,fg}-{light,dark}, --nma-cell-head-{bg,fg}-{light,dark},
--nma-cell-bar-bg-{light,dark}, --nma-cell-out-bg-{light,dark},
--nma-cell-gutter-{bg,fg}-{light,dark}, --nma-cell-active-line-{light,dark},
Expand Down
2 changes: 1 addition & 1 deletion js/src/cells/assets/page.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
.nma-cells-runtime { display: none; }

/* Floating "Run all" control (runtime-widget.mjs). Brand-coloured via the theme
variables set in _static/custom.css, with safe fallbacks. */
variables set in the kit stylesheet (site.css), with safe fallbacks. */
.nma-run-all-bar {
position: fixed;
right: 1rem;
Expand Down
2 changes: 1 addition & 1 deletion js/src/cells/src/editor.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// CodeMirror 6 editor for one cell. Colors come from CSS variables (cells.css),
// so a course restyles panels from _static/custom.css without touching JS.
// so a course restyles panels from its own CSS (nma.style.extra) without touching JS.
// No basicSetup: the explicit list is smaller and leaves out search/autocomplete popups.

import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
Expand Down
14 changes: 7 additions & 7 deletions js/src/scrolly/assets/engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@

const STYLE = `
.hud { position: absolute; top: 0; left: 0; right: 0; display: flex; align-items: center; gap: 16px; padding: 14px 28px; pointer-events: none; font-family: var(--nm-font-head, system-ui, sans-serif); }
.sec { font-weight: 600; font-size: 0.78rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--nm-mint, #5efbd7); min-width: 12ch; }
.bar { flex: 1; height: 3px; background: rgba(255,255,255,0.14); border-radius: 3px; overflow: hidden; }
.bar i { display: block; height: 100%; width: 0; background: linear-gradient(90deg, var(--nm-salmon, #fb7a7a), var(--nm-mint, #5efbd7)); transition: width 0.4s; }
.sec { font-weight: 600; font-size: 0.78rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--sc-accent, #008cbf); min-width: 12ch; }
.bar { flex: 1; height: 3px; background: var(--sc-track, rgba(26,26,26,0.12)); border-radius: 3px; overflow: hidden; }
.bar i { display: block; height: 100%; width: 0; background: linear-gradient(90deg, var(--sc-warm, #fa7a7a), var(--sc-accent, #008cbf)); transition: width 0.4s; }
.py[hidden] { display: none; }
.py { pointer-events: auto; flex: none; font: 600 0.74rem var(--nm-font-head, system-ui, sans-serif); letter-spacing: 0.04em; border: 1px solid var(--nm-mint, #5efbd7); border-radius: 999px; padding: 0.3rem 0.85rem; cursor: pointer; max-width: 34ch; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: var(--nm-mint, #5efbd7); color: var(--nm-indigo, #2a2952); }
.py[data-state="loading"] { background: transparent; color: var(--nm-mint, #5efbd7); cursor: progress; }
.py[data-state="ready"] { background: transparent; color: var(--nm-mint, #5efbd7); cursor: default; }
.py[data-state="error"] { background: var(--nm-salmon, #fb7a7a); border-color: var(--nm-salmon, #fb7a7a); }
.py { pointer-events: auto; flex: none; font: 600 0.74rem var(--nm-font-head, system-ui, sans-serif); letter-spacing: 0.04em; border: 1px solid var(--sc-accent, #008cbf); border-radius: 999px; padding: 0.3rem 0.85rem; cursor: pointer; max-width: 34ch; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: var(--sc-accent, #008cbf); color: var(--sc-accent-fg, #fff); }
.py[data-state="loading"] { background: transparent; color: var(--sc-accent, #008cbf); cursor: progress; }
.py[data-state="ready"] { background: transparent; color: var(--sc-accent, #008cbf); cursor: default; }
.py[data-state="error"] { background: var(--sc-warm, #fa7a7a); border-color: var(--sc-warm, #fa7a7a); color: #1a1a1a; }
`;

// Section width (px) from which the stage sits beside the cards.
Expand Down
Loading
Loading