diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6dcfce..762c212 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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] diff --git a/AGENTS.md b/AGENTS.md index 3eaaeb3..14979cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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) @@ -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`). diff --git a/README.md b/README.md index c1bc0d7..ab6fd26 100644 --- a/README.md +++ b/README.md @@ -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. @@ -27,6 +29,16 @@ which teaches the authoring conventions. project: github: https://github.com// # ... + 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 @@ -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: @@ -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 @@ -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 ``` diff --git a/js/README.md b/js/README.md index 28ae868..41caae7 100644 --- a/js/README.md +++ b/js/README.md @@ -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 diff --git a/js/src/cells/assets/cells.css b/js/src/cells/assets/cells.css index c9927e4..6eaf4fe 100644 --- a/js/src/cells/assets/cells.css +++ b/js/src/cells/assets/cells.css @@ -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}, diff --git a/js/src/cells/assets/page.css b/js/src/cells/assets/page.css index 3991697..0011863 100644 --- a/js/src/cells/assets/page.css +++ b/js/src/cells/assets/page.css @@ -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; diff --git a/js/src/cells/src/editor.mjs b/js/src/cells/src/editor.mjs index 6ce2483..6debd69 100644 --- a/js/src/cells/src/editor.mjs +++ b/js/src/cells/src/editor.mjs @@ -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'; diff --git a/js/src/scrolly/assets/engine.mjs b/js/src/scrolly/assets/engine.mjs index 128f7d8..e7d9bee 100644 --- a/js/src/scrolly/assets/engine.mjs +++ b/js/src/scrolly/assets/engine.mjs @@ -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. diff --git a/js/src/scrolly/assets/runnable.css b/js/src/scrolly/assets/runnable.css index c62a421..05c8950 100644 --- a/js/src/scrolly/assets/runnable.css +++ b/js/src/scrolly/assets/runnable.css @@ -1,27 +1,28 @@ /* Runnable Python panel (inside the widget's shadow DOM). - Colours come from the --nm-* variables set on .scrolly in scrolly.css. */ + Colours come from the --sc-* variables set on .scrolly in scrolly.css (they + inherit into the shadow DOM and switch with the site's light/dark mode). */ :host { display: block; width: 100%; } * { box-sizing: border-box; } [hidden] { display: none !important; } .fl-codebox { width: 100%; - background: #14131f; - border: 1px solid rgba(185, 184, 255, 0.28); + background: var(--sc-code-bg); + border: 1px solid var(--sc-line); border-radius: 12px; overflow: hidden; - box-shadow: 0 12px 34px rgba(8, 7, 30, 0.5); - color: #ecebff; + box-shadow: var(--sc-shadow); + color: var(--sc-code-text); font-family: var(--nm-font-body, system-ui, sans-serif); text-align: left; } -.fl-codehead { display: flex; justify-content: space-between; padding: 0.45rem 1rem; background: #22214a; font: 600 0.72rem var(--nm-font-head, system-ui, sans-serif); letter-spacing: 0.1em; text-transform: uppercase; color: var(--nm-lilac, #b9b8ff); } +.fl-codehead { display: flex; justify-content: space-between; padding: 0.45rem 1rem; background: var(--sc-code-bar); border-bottom: 1px solid var(--sc-line); font: 600 0.72rem var(--nm-font-head, system-ui, sans-serif); letter-spacing: 0.1em; text-transform: uppercase; color: var(--sc-code-head-fg); } -.fl-params { display: flex; flex-wrap: wrap; gap: 0.4rem 1.4rem; padding: 0.55rem 1rem; background: #1b1a36; border-bottom: 1px solid rgba(185, 184, 255, 0.2); } -.fl-params label { display: flex; align-items: center; gap: 0.5rem; font: 600 0.8rem ui-monospace, Consolas, monospace; color: var(--nm-lilac, #b9b8ff); } -.fl-params input[type=range] { width: 9rem; accent-color: var(--nm-mint, #5efbd7); } -.fl-params output { min-width: 4ch; color: var(--nm-mint, #5efbd7); text-align: right; } -.fl-params select { font: inherit; background: #14131f; color: var(--nm-mint, #5efbd7); border: 1px solid rgba(185, 184, 255, 0.4); border-radius: 6px; padding: 0.15rem 0.4rem; } +.fl-params { display: flex; flex-wrap: wrap; gap: 0.4rem 1.4rem; padding: 0.55rem 1rem; background: var(--sc-code-bar); border-bottom: 1px solid var(--sc-line); } +.fl-params label { display: flex; align-items: center; gap: 0.5rem; font: 600 0.8rem ui-monospace, Consolas, monospace; color: var(--sc-code-head-fg); } +.fl-params input[type=range] { width: 9rem; accent-color: var(--sc-accent); } +.fl-params output { min-width: 4ch; color: var(--sc-accent); text-align: right; } +.fl-params select { font: inherit; background: var(--sc-code-bg); color: var(--sc-accent); border: 1px solid var(--sc-line); border-radius: 6px; padding: 0.15rem 0.4rem; } .fl-editor { position: relative; max-height: 46vh; overflow: auto; } .fl-editor .fl-hl, .fl-editor .fl-ta { @@ -31,37 +32,37 @@ white-space: pre; tab-size: 4; } -.fl-editor .fl-hl { color: #ecebff; overflow: hidden; pointer-events: none; } +.fl-editor .fl-hl { color: var(--sc-code-text); overflow: hidden; pointer-events: none; } .fl-editor .fl-ta { position: absolute; inset: 0; width: 100%; height: 100%; - color: transparent; caret-color: var(--nm-mint, #5efbd7); background: transparent; border: 0; outline: 0; resize: none; overflow: hidden; + color: transparent; caret-color: var(--sc-accent); background: transparent; border: 0; outline: 0; resize: none; overflow: hidden; } -.fl-editor .fl-ta::selection { background: rgba(94, 251, 215, 0.28); } -.fl-editor:focus-within { box-shadow: inset 0 0 0 2px rgba(94, 251, 215, 0.55); } +.fl-editor .fl-ta::selection { background: color-mix(in srgb, var(--sc-accent) 30%, transparent); } +.fl-editor:focus-within { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--sc-accent) 55%, transparent); } -.fl-actions { display: flex; align-items: center; gap: 0.6rem; padding: 0.45rem 1rem; background: #1b1a36; border-top: 1px solid rgba(185, 184, 255, 0.2); } +.fl-actions { display: flex; align-items: center; gap: 0.6rem; padding: 0.45rem 1rem; background: var(--sc-code-bar); border-top: 1px solid var(--sc-line); } .mode-output .fl-actions { border-top: 0; background: transparent; padding: 0.35rem 0.6rem; } .fl-actions button { font: 600 0.78rem var(--nm-font-head, system-ui, sans-serif); border: 0; border-radius: 999px; padding: 0.3rem 0.95rem; cursor: pointer; } -.fl-run { background: var(--nm-mint, #5efbd7); color: var(--nm-indigo, #2a2952); } +.fl-run { background: var(--sc-accent); color: var(--sc-accent-fg); } .fl-run:disabled { opacity: 0.55; cursor: progress; } -.fl-reset, .fl-toggle { background: transparent; color: var(--nm-lilac, #b9b8ff); border: 1px solid rgba(185, 184, 255, 0.5) !important; } -.fl-status { font-size: 0.76rem; color: var(--nm-muted, #b4b3e6); } +.fl-reset, .fl-toggle { background: transparent; color: var(--sc-code-text); border: 1px solid var(--sc-line) !important; } +.fl-status { font-size: 0.76rem; color: var(--sc-muted); } -.fl-out { border-top: 1px solid rgba(185, 184, 255, 0.2); background: #0e0d18; padding: 0.55rem 1.1rem 0.75rem; } +.fl-out { border-top: 1px solid var(--sc-line); background: var(--sc-code-out); padding: 0.55rem 1.1rem 0.75rem; } .mode-output .fl-out { border-top: 0; } -.fl-out pre { margin: 0.25rem 0 0; font: 0.84rem/1.55 ui-monospace, "Cascadia Code", Consolas, monospace; color: #c9f5ea; white-space: pre-wrap; max-height: 30vh; overflow: auto; } +.fl-out pre { margin: 0.25rem 0 0; font: 0.84rem/1.55 ui-monospace, "Cascadia Code", Consolas, monospace; color: var(--sc-code-text); white-space: pre-wrap; max-height: 30vh; overflow: auto; } .fl-figs img { display: block; max-width: 100%; max-height: 48vh; margin: 0.5rem auto 0; border-radius: 8px; background: #fff; } -.fl-err { color: var(--nm-salmon, #fb7a7a); font-size: 0.86rem; margin-top: 0.3rem; } -.fl-err details { margin-top: 0.25rem; color: var(--nm-muted, #b4b3e6); } -.fl-err pre { font: 0.74rem/1.5 ui-monospace, Consolas, monospace; color: #f5c1c1; } -.fl-note { margin: 0 0 0.3rem; font-size: 0.78rem; color: var(--nm-salmon, #fb7a7a); } -.fl-empty { margin: 0; font-size: 0.8rem; color: var(--nm-muted, #b4b3e6); font-style: italic; } +.fl-err { color: var(--sc-warm-text); font-size: 0.86rem; margin-top: 0.3rem; } +.fl-err details { margin-top: 0.25rem; color: var(--sc-muted); } +.fl-err pre { font: 0.74rem/1.5 ui-monospace, Consolas, monospace; color: var(--sc-warm-text); } +.fl-note { margin: 0 0 0.3rem; font-size: 0.78rem; color: var(--sc-warm-text); } +.fl-empty { margin: 0; font-size: 0.8rem; color: var(--sc-muted); font-style: italic; } -.tk-c { color: #8b8ac7; font-style: italic; } -.tk-s { color: var(--nm-mint, #5efbd7); font-style: normal; } -.tk-k { color: var(--nm-salmon, #fb7a7a); font-style: normal; } -.tk-n { color: var(--nm-lilac, #b9b8ff); font-style: normal; } -.tk-f { color: #7cc4ff; font-style: normal; } -.tk-b { font-style: normal; color: #fff; background: rgba(251, 122, 122, 0.35); border-radius: 4px; } +.tk-c { color: var(--sc-tok-comment); font-style: italic; } +.tk-s { color: var(--sc-tok-string); font-style: normal; } +.tk-k { color: var(--sc-tok-keyword); font-style: normal; } +.tk-n { color: var(--sc-tok-number); font-style: normal; } +.tk-f { color: var(--sc-tok-function); font-style: normal; } +.tk-b { font-style: normal; color: inherit; background: color-mix(in srgb, var(--sc-warm) 30%, transparent); border-radius: 4px; } .is-collapsed .fl-editor { display: none; } diff --git a/js/src/scrolly/assets/scrolly.css b/js/src/scrolly/assets/scrolly.css index 58a7a5d..21d241d 100644 --- a/js/src/scrolly/assets/scrolly.css +++ b/js/src/scrolly/assets/scrolly.css @@ -1,4 +1,4 @@ -/* Scrollytelling page layout (plugins/scrolly/). +/* Scrollytelling page layout and colours (js/src/scrolly/). Attached to the engine widget so MyST bundles it; engine.mjs copies it into . Without JavaScript the page is a plain linear document; the pinned layout switches on when the engine sets data-ready. @@ -26,28 +26,95 @@ body:has(.myst-primary-sidebar.lg\:hidden) .article-grid:not(:has(> .lg\:col-margin-right)) > .scrolly-col { grid-column: screen-inset; } } +/* ---- colours ---- + The section follows the site: light and dark mode (the theme's `.dark` class + on ), the brand colours from the kit stylesheet (--nma-brand-*, which a + course changes with `nma.brand`) and the kit's syntax colours (--nma-tok-*, + shared with the editable cells). The hex values are the Neuromatch defaults, + used when the kit stylesheet is not loaded. runnable.css and the HUD in + engine.mjs read the same --sc-* tokens through the shadow DOM. */ .scrolly { --scrolly-top: 60px; - --nm-indigo: #2a2952; - --nm-indigo-deep: #1c1b3e; - --nm-purple: #45429c; - --nm-blue: #008cbf; - --nm-salmon: #fa7a7a; - --nm-mint: #5efbd7; - --nm-lilac: #b9b8ff; - --nm-cream: #f3f0ec; - --nm-ink: #161922; - --nm-muted: #b4b3e6; - --nm-text: #f3f0ec; + --sc-primary: var(--nma-brand-primary, #008cbf); + --sc-primary-light: var(--nma-brand-primary-light, #35b4dc); + --sc-secondary: var(--nma-brand-secondary, #45429c); + --sc-warm: var(--nma-brand-accent, #fa7a7a); + + --sc-bg: #f7f8fa; + --sc-text: #1a1a1a; + --sc-muted: #7a7a7a; + --sc-line: #e6e6e6; + --sc-heading: var(--sc-secondary); + --sc-title: var(--sc-secondary); + --sc-link: var(--sc-primary); + --sc-accent: var(--sc-primary); + --sc-accent-fg: #ffffff; + --sc-warm-text: color-mix(in srgb, var(--sc-warm) 60%, #1a1a1a); + --sc-glow-1: color-mix(in srgb, var(--sc-secondary) 10%, transparent); + --sc-glow-2: color-mix(in srgb, var(--sc-primary) 12%, transparent); + --sc-track: rgba(26, 26, 26, 0.12); + --sc-chip-bg: color-mix(in srgb, var(--sc-primary) 6%, #ffffff); + --sc-chip-line: color-mix(in srgb, var(--sc-primary) 28%, transparent); + + --sc-card-bg: #ffffff; + --sc-card-text: #1a1a1a; + --sc-card-code: color-mix(in srgb, var(--sc-primary) 10%, #ffffff); + --sc-shadow: 0 10px 30px rgba(26, 26, 26, 0.1); + + --sc-code-bg: #ffffff; + --sc-code-bar: #f7f8fa; + --sc-code-out: #f7f8fa; + --sc-code-text: #1a1a1a; + --sc-code-head-fg: var(--sc-secondary); + --sc-tok-comment: var(--nma-tok-comment-light, #7a7a7a); + --sc-tok-string: var(--nma-tok-string-light, #0e7c86); + --sc-tok-keyword: var(--nma-tok-keyword-light, #45429c); + --sc-tok-number: var(--nma-tok-number-light, #b5484b); + --sc-tok-function: var(--nma-tok-function-light, #006b8f); + --nm-font-body: inherit; --nm-font-head: inherit; position: relative; margin-block: 1.5rem; - color: var(--nm-text); - background: var(--nm-indigo-deep); + color: var(--sc-text); + background: var(--sc-bg); + border: 1px solid var(--sc-line); border-radius: 12px; padding: 1rem; } +.dark .scrolly { + --sc-bg: #232323; + --sc-text: #f7f8fa; + --sc-muted: #bdcfcf; + --sc-line: #3a3a3a; + --sc-heading: var(--nma-tok-keyword-dark, #b9b8ff); + --sc-title: #f7f8fa; + --sc-link: var(--sc-primary-light); + --sc-accent: var(--sc-primary-light); + --sc-accent-fg: #14131f; + --sc-warm-text: color-mix(in srgb, var(--sc-warm) 75%, #ffffff); + --sc-glow-1: color-mix(in srgb, var(--sc-secondary) 45%, transparent); + --sc-glow-2: color-mix(in srgb, var(--sc-primary) 18%, transparent); + --sc-track: rgba(255, 255, 255, 0.14); + --sc-chip-bg: rgba(255, 255, 255, 0.05); + --sc-chip-line: color-mix(in srgb, var(--sc-primary-light) 30%, transparent); + + --sc-card-bg: #2e2e2e; + --sc-card-text: #f7f8fa; + --sc-card-code: rgba(255, 255, 255, 0.1); + --sc-shadow: 0 14px 40px rgba(0, 0, 0, 0.45); + + --sc-code-bg: #1b1b1b; + --sc-code-bar: #262626; + --sc-code-out: #151515; + --sc-code-text: #e6e6e6; + --sc-code-head-fg: #bdcfcf; + --sc-tok-comment: var(--nma-tok-comment-dark, #9aa7a7); + --sc-tok-string: var(--nma-tok-string-dark, #5efbd7); + --sc-tok-keyword: var(--nma-tok-keyword-dark, #b9b8ff); + --sc-tok-number: var(--nma-tok-number-dark, #ff9e9e); + --sc-tok-function: var(--nma-tok-function-dark, #5ec8ec); +} .scrolly *, .scrolly *::before, .scrolly *::after { box-sizing: border-box; } .scrolly-engine { display: block; } .scrolly-stage { margin-bottom: 1.5rem; } @@ -77,8 +144,8 @@ body:has(.scrolly) .myst-jp-nb-toolbar-power { display: none !important; } z-index: 0; border-radius: 12px; background: - radial-gradient(ellipse 60% 50% at 78% 20%, rgba(68, 67, 156, 0.55), transparent 70%), - radial-gradient(ellipse 40% 40% at 10% 95%, rgba(0, 137, 247, 0.16), transparent 70%); + radial-gradient(ellipse 60% 50% at 78% 20%, var(--sc-glow-1), transparent 70%), + radial-gradient(ellipse 40% 40% at 10% 95%, var(--sc-glow-2), transparent 70%); } .scrolly[data-ready="true"] .scrolly-view { position: absolute; @@ -104,59 +171,60 @@ body:has(.scrolly) .myst-jp-nb-toolbar-power { display: none !important; } pointer-events: auto; max-width: 32rem; min-width: 0; /* flex item in .scrolly-step: don't grow to the widest code line */ - background: var(--nm-cream); - color: var(--nm-ink); + background: var(--sc-card-bg); + color: var(--sc-card-text); + border: 1px solid var(--sc-line); border-radius: 14px; - border-left: 6px solid var(--nm-salmon); + border-left: 6px solid var(--sc-warm); padding: 1.1rem 1.35rem 1.25rem; - box-shadow: 0 14px 40px rgba(8, 7, 30, 0.45); + box-shadow: var(--sc-shadow); line-height: 1.6; } .scrolly[data-ready="true"] .scrolly-card { opacity: 0.32; transform: translateX(-10px) scale(0.985); transition: opacity 0.35s, transform 0.35s; } .scrolly[data-ready="true"] .scrolly-step.is-active .scrolly-card { opacity: 1; transform: none; } .scrolly-card > :first-child { margin-top: 0; } .scrolly-card > :last-child { margin-bottom: 0; } -.scrolly-card :is(h1, h2, h3, h4, h5, h6) { display: block; border: 0; padding: 0; margin: 0.2rem 0 0.45rem; font-weight: 600; font-size: 1.35rem; line-height: 1.2; color: var(--nm-indigo); } +.scrolly-card :is(h1, h2, h3, h4, h5, h6) { display: block; border: 0; padding: 0; margin: 0.2rem 0 0.45rem; font-weight: 600; font-size: 1.35rem; line-height: 1.2; color: var(--sc-heading); } .scrolly-card p { margin: 0 0 0.6rem; font-size: 1rem; } -.scrolly-card :is(p, li, strong, em, span, b, i, td, th) { color: var(--nm-ink); } -.scrolly-card code { background: #e4e0f7; color: var(--nm-indigo); padding: 0.05em 0.4em; border-radius: 5px; font-size: 0.88em; } -.scrolly-card a { color: var(--nm-purple); } +.scrolly-card :is(p, li, strong, em, span, b, i, td, th) { color: var(--sc-card-text); } +.scrolly-card code { background: var(--sc-card-code); color: var(--sc-heading); padding: 0.05em 0.4em; border-radius: 5px; font-size: 0.88em; } +.scrolly-card a { color: var(--sc-link); } .scrolly-card .katex-display { overflow-x: auto; overflow-y: hidden; } .scrolly-card details { margin: 0.5rem 0; } .scrolly-card pre { max-width: 100%; overflow-x: auto; } .scrolly-card .nma-solution-link p { margin: 0.4rem 0; } .scrolly-kicker { margin: 0 0 0.1rem; } -.scrolly-kicker p { margin: 0; font-weight: 600; font-size: 0.72rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--nm-purple) !important; } +.scrolly-kicker p { margin: 0; font-weight: 600; font-size: 0.72rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--sc-heading) !important; } /* ---- stage content ---- */ -.scrolly-view { color: var(--nm-text); } +.scrolly-view { color: var(--sc-text); } .scrolly-view :is(p, li, strong, em, span) { color: inherit; } -.scrolly-view :is(h1, h2, h3, h4) { display: block; border: 0; padding: 0; color: #fff; } -.scrolly-view a { color: var(--nm-mint); } -.scrolly-view img { max-width: 100%; max-height: 70vh; width: auto; height: auto; border-radius: 10px; background: #fff; margin: 0 auto; display: block; } +.scrolly-view :is(h1, h2, h3, h4) { display: block; border: 0; padding: 0; color: var(--sc-title); } +.scrolly-view a { color: var(--sc-link); } +.scrolly-view img { max-width: 100%; max-height: 70vh; width: auto; height: auto; border-radius: 10px; background: #fff; border: 1px solid var(--sc-line); margin: 0 auto; display: block; } .scrolly-view video { max-width: 100%; max-height: 70vh; border-radius: 10px; margin: 0 auto; display: block; } .scrolly-view figure { margin: 0; } -.scrolly-view figcaption, .scrolly-view figcaption * { color: var(--nm-muted); } +.scrolly-view figcaption, .scrolly-view figcaption * { color: var(--sc-muted); } .scrolly-view iframe { width: 100%; aspect-ratio: 16 / 9; height: auto; border: 0; border-radius: 10px; background: #000; } .scrolly-view .nma-video p, .scrolly-view .nma-slides p { margin: 0.3rem 0; font-size: 0.85rem; } -.scrolly-view .nma-video [role="tab"] { color: var(--nm-lilac); } +.scrolly-view .nma-video [role="tab"] { color: var(--sc-link); } .scrolly-view .scrolly-runnable { width: 100%; } .scrolly-view.is-list ol, .scrolly-view.is-list ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.75rem; counter-reset: chip; } -.scrolly-view.is-list li { counter-increment: chip; display: grid; grid-template-columns: 2.6rem 1fr; align-items: center; gap: 0.9rem; font-size: 1.05rem; line-height: 1.45; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(185, 184, 255, 0.2); border-radius: 12px; padding: 0.75rem 1rem; margin: 0; } -.scrolly-view.is-list li::before { content: counter(chip); display: grid; place-items: center; width: 2.6rem; height: 2.6rem; border-radius: 50%; background: var(--nm-mint); color: var(--nm-indigo); font-weight: 700; font-size: 1.1rem; } +.scrolly-view.is-list li { counter-increment: chip; display: grid; grid-template-columns: 2.6rem 1fr; align-items: center; gap: 0.9rem; font-size: 1.05rem; line-height: 1.45; background: var(--sc-chip-bg); border: 1px solid var(--sc-chip-line); border-radius: 12px; padding: 0.75rem 1rem; margin: 0; } +.scrolly-view.is-list li::before { content: counter(chip); display: grid; place-items: center; width: 2.6rem; height: 2.6rem; border-radius: 50%; background: var(--sc-accent); color: var(--sc-accent-fg); font-weight: 700; font-size: 1.1rem; } .scrolly-view.is-list li > p { margin: 0; } .scrolly-view.is-list :is(h2, h3, h4) { margin: 0 0 0.5rem; font-size: clamp(1.4rem, 4cqi, 2.2rem); font-weight: 700; } /* ---- hero (title and closing screens) ---- MyST re-levels headings, so the title is styled by position, not by tag. */ .scrolly-view.is-hero { max-width: 72rem; margin-inline: auto; padding: 0 clamp(0.5rem, 5cqi, 4rem); } -.scrolly-view.is-hero .scrolly-kicker p { font-size: 0.85rem; letter-spacing: 0.32em; color: var(--nm-mint) !important; margin-bottom: 0.6rem; } +.scrolly-view.is-hero .scrolly-kicker p { font-size: 0.85rem; letter-spacing: 0.32em; color: var(--sc-accent) !important; margin-bottom: 0.6rem; } .scrolly-view.is-hero :is(h1, h2, h3, h4, h5, h6) { display: block; margin: 0; padding: 0; border: 0; } -.scrolly-view.is-hero :is(h1, h2, h3, h4, h5, h6):not(:is(h1, h2, h3, h4, h5, h6) ~ *) { font-weight: 700; font-size: clamp(2rem, 8cqi, 4.6rem); line-height: 1.05; color: #fff; } -.scrolly-view.is-hero :is(h1, h2, h3, h4, h5, h6) ~ :is(h1, h2, h3, h4, h5, h6) { font-weight: 500; font-size: 1.15rem; color: var(--nm-lilac); margin: 0.9rem 0 0.3rem; } -.scrolly-view.is-hero :is(h1, h2, h3, h4, h5, h6) em { font-style: normal; background: linear-gradient(90deg, var(--nm-salmon), var(--nm-mint)); -webkit-background-clip: text; background-clip: text; color: transparent; } +.scrolly-view.is-hero :is(h1, h2, h3, h4, h5, h6):not(:is(h1, h2, h3, h4, h5, h6) ~ *) { font-weight: 700; font-size: clamp(2rem, 8cqi, 4.6rem); line-height: 1.05; color: var(--sc-title); } +.scrolly-view.is-hero :is(h1, h2, h3, h4, h5, h6) ~ :is(h1, h2, h3, h4, h5, h6) { font-weight: 500; font-size: 1.15rem; color: var(--sc-link); margin: 0.9rem 0 0.3rem; } +.scrolly-view.is-hero :is(h1, h2, h3, h4, h5, h6) em { font-style: normal; background: linear-gradient(90deg, var(--sc-warm), var(--sc-accent)); -webkit-background-clip: text; background-clip: text; color: transparent; } .scrolly-view.is-hero p { margin: 0.5rem 0; font-size: 0.98rem; line-height: 1.5; max-width: 52rem; } -.scrolly-view.is-hero p strong { color: var(--nm-salmon); } +.scrolly-view.is-hero p strong { color: var(--sc-warm-text); } /* ---- narrow sections: stage on top, cards below it ---- Keep the 760px breakpoint in sync with WIDE in engine.mjs. */ diff --git a/python/README.md b/python/README.md index 0f7af05..c512103 100644 --- a/python/README.md +++ b/python/README.md @@ -25,8 +25,9 @@ folder with `myst.yml`). |---|---| | `nma-kit check` | Validate the content rules and the `nma:` block of `myst.yml` | | `nma-kit notebooks [--dry-run]` | Generate student and instructor notebooks and the solution files into `notebooks/` | +| `nma-kit sync-assets` | Write the site branding (stylesheet, logos, favicon, Rubik font) to `_nma/`, which the course's `myst.yml` points at | | `nma-kit render-scrolly` | Run each scrolly page's instructor notebook and save outputs to `_build/scrolly/` | -| `nma-kit prepare-site [--dry-run] [--kit-version V]` | Add badges and fallback cells to the pages and pin the kit URL in `myst.yml`. **CI only: it edits files in place, never commit the result** | +| `nma-kit prepare-site [--dry-run] [--kit-version V]` | Add badges and fallback cells to the pages, pin the kit URL in `myst.yml` and run `sync-assets`. **CI only: it edits files in place, never commit the result** | | `nma-kit publish-notebooks [--remote R] [--message M]` | Push `notebooks/` to the notebooks branch (created if missing) | Exit codes: `0` ok, `1` content problems or a failed publish, `2` usage or @@ -47,8 +48,33 @@ nma: url: https://... project: ... user_key: ... + brand: # site colours, "#RRGGBB" each (see below) + primary: "#008CBF" + style: + extra: _static/extra.css # your own CSS, appended after the kit's ``` +### Site branding + +`sync-assets` writes `_nma/site.css`, `logo.svg`, `logo-dark.svg` and +`favicon.png`. List them in `myst.yml`: + +```yaml +site: + options: + style: _nma/site.css + logo: _nma/logo.svg + logo_dark: _nma/logo-dark.svg + favicon: _nma/favicon.png +``` + +The stylesheet is the kit's (Neuromatch palette, Rubik embedded, so the +kit's own CSS makes no font requests, styles for the kit's widgets), then your +`nma.brand` colours, then your `nma.style.extra` CSS. Brand keys: `primary`, +`primary_hover`, `primary_light`, `secondary`, `accent`. Setting only `primary` +derives the hover and light variants. `_nma/` ignores itself, so don't commit +it; run `sync-assets` again after upgrading the kit. + Values in `nma:` must be plain strings in one level of sections. ## Versions diff --git a/python/src/nma_kit/assets/favicon.png b/python/src/nma_kit/assets/favicon.png new file mode 100644 index 0000000..fbaeba6 Binary files /dev/null and b/python/src/nma_kit/assets/favicon.png differ diff --git a/python/src/nma_kit/assets/fonts/OFL.txt b/python/src/nma_kit/assets/fonts/OFL.txt new file mode 100644 index 0000000..b25d078 --- /dev/null +++ b/python/src/nma_kit/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik) Rubik-Italic[wght].ttf: Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/python/src/nma_kit/assets/fonts/Rubik-400-italic.woff2 b/python/src/nma_kit/assets/fonts/Rubik-400-italic.woff2 new file mode 100644 index 0000000..3aa5fbf Binary files /dev/null and b/python/src/nma_kit/assets/fonts/Rubik-400-italic.woff2 differ diff --git a/python/src/nma_kit/assets/fonts/Rubik-400-normal.woff2 b/python/src/nma_kit/assets/fonts/Rubik-400-normal.woff2 new file mode 100644 index 0000000..29ffcf2 Binary files /dev/null and b/python/src/nma_kit/assets/fonts/Rubik-400-normal.woff2 differ diff --git a/python/src/nma_kit/assets/fonts/Rubik-500-normal.woff2 b/python/src/nma_kit/assets/fonts/Rubik-500-normal.woff2 new file mode 100644 index 0000000..02be45c Binary files /dev/null and b/python/src/nma_kit/assets/fonts/Rubik-500-normal.woff2 differ diff --git a/python/src/nma_kit/assets/fonts/Rubik-700-normal.woff2 b/python/src/nma_kit/assets/fonts/Rubik-700-normal.woff2 new file mode 100644 index 0000000..5f295d9 Binary files /dev/null and b/python/src/nma_kit/assets/fonts/Rubik-700-normal.woff2 differ diff --git a/python/src/nma_kit/assets/logo-dark.svg b/python/src/nma_kit/assets/logo-dark.svg new file mode 100644 index 0000000..3899e02 --- /dev/null +++ b/python/src/nma_kit/assets/logo-dark.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/python/src/nma_kit/assets/logo.svg b/python/src/nma_kit/assets/logo.svg new file mode 100644 index 0000000..93b3915 --- /dev/null +++ b/python/src/nma_kit/assets/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/python/src/nma_kit/assets/site.css b/python/src/nma_kit/assets/site.css new file mode 100644 index 0000000..8df1094 --- /dev/null +++ b/python/src/nma_kit/assets/site.css @@ -0,0 +1,205 @@ +/* Neuromatch course kit: site-wide styles + * + * `nma-kit sync-assets` writes this file, with the fonts embedded, to + * _nma/site.css. Courses list it as `site.options.style`. It applies the + * Neuromatch brand (palette, typography, accents) and styles the kit's own + * interactive widgets (editable cells, scrollytelling). + * + * Brand palette (Neuromatch Brand Book 2026): + * Primary Purple #45429C + * Blue #008CBF + * Light teal #BDCFCF + * Accent Blush #FA7A7A + * Neutrals Off white #F7F8FA Light grey #E6E6E6 + * Mid grey #7A7A7A Black #1A1A1A + * + * A course changes the colours through the `nma.brand` block in myst.yml + * (see README); `nma-kit sync-assets` then appends a `:root { ... }` block + * that redefines the --nma-brand-* variables below. Everything else follows. + */ + +/* The Rubik font faces (SIL OFL 1.1) are inserted below by sync-assets. */ +/*FONTS*/ + +/* ------------------------------------------------------------------------- + * Brand variables: the only values a course is meant to change. + * ---------------------------------------------------------------------- */ +:root { + --nma-brand-primary: #008CBF; /* Neuromatch Blue: links, accents, buttons */ + --nma-brand-primary-hover: #006B8F; /* hover / pressed */ + --nma-brand-primary-light: #35B4DC; /* the same accent on dark backgrounds */ + --nma-brand-secondary: #45429C; /* Purple: header surface, tips */ + --nma-brand-accent: #FA7A7A; /* Blush: exercise cells */ + + --nma-font-sans: 'Rubik', Arial, ui-sans-serif, system-ui, sans-serif; +} + +body, +button, +input, +select, +textarea, +h1, h2, h3, h4, h5, h6 { + font-family: var(--nma-font-sans); +} + +/* ------------------------------------------------------------------------- + * Book theme colours, light mode (:root) overrides the theme defaults. + * ---------------------------------------------------------------------- */ +:root { + --myst-color-primary: var(--nma-brand-primary); + --myst-color-primary-hover: var(--nma-brand-primary-hover); + + --myst-color-link: var(--nma-brand-primary); + --myst-color-link-hover: var(--nma-brand-primary-hover); + --myst-color-link-underline: var(--nma-brand-primary); + + --myst-color-active: var(--nma-brand-primary); + --myst-color-active-bg: color-mix(in srgb, var(--nma-brand-primary) 15%, transparent); + --myst-color-active-surface: #F7F8FA; + + --myst-color-focus-ring: var(--nma-brand-primary); + --myst-color-focus-outline: #BDCFCF; + + --myst-color-accent-text: var(--nma-brand-primary); + --myst-color-accent-surface: #F7F8FA; + --myst-color-accent-surface-border: #BDCFCF; + --myst-color-accent-surface-text: var(--nma-brand-primary-hover); + + --myst-color-inverse-bg: var(--nma-brand-secondary); + --myst-color-code: var(--nma-brand-primary); + + /* Neutrals */ + --myst-color-text: #1A1A1A; + --myst-color-prose-body: #1A1A1A; + --myst-color-text-secondary: #7A7A7A; + --myst-color-text-tertiary: #7A7A7A; + --myst-color-bg-secondary: #F7F8FA; + --myst-color-surface: #F7F8FA; + --myst-color-surface-hover: #E6E6E6; + --myst-color-border: #E6E6E6; + --myst-color-border-strong: #BDCFCF; + + /* Admonition accents on brand */ + --myst-color-info: var(--nma-brand-primary); + --myst-color-info-text: var(--nma-brand-primary-hover); + --myst-color-tip: var(--nma-brand-secondary); + --myst-color-tip-text: var(--nma-brand-secondary); + + /* Interactive code panels (editable cells), light */ + --nma-cell-accent-light: var(--nma-brand-primary); + --nma-cell-exercise-accent-light: var(--nma-brand-accent); + --nma-cell-font-family: var(--nma-font-sans); + + /* Syntax highlighting tokens (CodeMirror cells), light. Fixed values: + chosen for AA contrast on the light cell background. */ + --nma-tok-keyword-light: #45429C; + --nma-tok-function-light: #006B8F; + --nma-tok-definition-light: #006B8F; + --nma-tok-string-light: #0E7C86; + --nma-tok-number-light: #B5484B; + --nma-tok-comment-light: #7A7A7A; +} + +/* ------------------------------------------------------------------------- + * Book theme colours, dark mode. The accent shifts to the lighter brand + * colour so it stays legible on the dark background. + * ---------------------------------------------------------------------- */ +html.dark, +.dark { + --myst-color-primary: var(--nma-brand-primary); + --myst-color-primary-hover: var(--nma-brand-primary-light); + + --myst-color-link: var(--nma-brand-primary-light); + --myst-color-link-hover: #BDCFCF; + --myst-color-link-underline: var(--nma-brand-primary); + + --myst-color-active: var(--nma-brand-primary-light); + --myst-color-active-bg: color-mix(in srgb, var(--nma-brand-primary) 25%, transparent); + + --myst-color-focus-ring: var(--nma-brand-primary-light); + --myst-color-focus-outline: var(--nma-brand-primary-light); + + --myst-color-accent-text: #BDCFCF; + --myst-color-accent-surface-text: #BDCFCF; + + --myst-color-inverse-bg: var(--nma-brand-primary); + --myst-color-code: var(--nma-brand-primary-light); + + --myst-color-info: var(--nma-brand-primary-light); + --myst-color-info-text: var(--nma-brand-primary-light); + --myst-color-tip: var(--nma-brand-primary-light); + --myst-color-tip-text: var(--nma-brand-primary-light); + + /* Dark neutrals (restated: the :root overrides above would otherwise leak + into dark mode, since this file loads after the theme and :root / .dark + have equal specificity). */ + --myst-color-text: #F7F8FA; + --myst-color-prose-body: #E6E6E6; + --myst-color-text-secondary: #BDCFCF; + --myst-color-text-tertiary: #7A7A7A; + --myst-color-bg-secondary: #232323; + --myst-color-surface: #232323; + --myst-color-surface-hover: #2E2E2E; + --myst-color-border: #3A3A3A; + --myst-color-border-strong: #4A4A4A; + + /* Interactive code panels (editable cells), dark */ + --nma-cell-accent-dark: var(--nma-brand-primary-light); + --nma-cell-exercise-accent-dark: var(--nma-brand-accent); + + /* Syntax highlighting tokens (CodeMirror cells), dark. */ + --nma-tok-keyword-dark: #B9B8FF; + --nma-tok-function-dark: #5EC8EC; + --nma-tok-definition-dark: #5EC8EC; + --nma-tok-string-dark: #5EFBD7; + --nma-tok-number-dark: #FF9E9E; + --nma-tok-comment-dark: #9AA7A7; +} + +/* ------------------------------------------------------------------------- + * Static code blocks (highlight.js) — brand palette matching the CodeMirror + * token colours above, so the two highlighters look consistent. Light is the + * default (no html class); dark is html.dark. Selectors are one step more + * specific than the theme's so they win. + * ---------------------------------------------------------------------- */ +html:not(.dark) .hljs-keyword, +html:not(.dark) .hljs-literal, +html:not(.dark) .hljs-selector-tag, +html:not(.dark) .hljs-name, +html:not(.dark) .hljs-symbol, +html:not(.dark) .hljs-tag { color: #45429C; } +html:not(.dark) .hljs-built_in, +html:not(.dark) .hljs-type, +html:not(.dark) .hljs-title, +html:not(.dark) .hljs-section { color: #006B8F; } +html:not(.dark) .hljs-string, +html:not(.dark) .hljs-quote, +html:not(.dark) .hljs-attr, +html:not(.dark) .hljs-attribute { color: #0E7C86; } +html:not(.dark) .hljs-number, +html:not(.dark) .hljs-meta { color: #B5484B; } +html:not(.dark) .hljs-comment { color: #7A7A7A; font-style: italic; } + +html.dark .hljs-keyword, +html.dark .hljs-literal, +html.dark .hljs-selector-tag, +html.dark .hljs-name, +html.dark .hljs-symbol, +html.dark .hljs-tag { color: #B9B8FF; } +html.dark .hljs-built_in, +html.dark .hljs-type, +html.dark .hljs-title, +html.dark .hljs-section { color: #5EC8EC; } +html.dark .hljs-string, +html.dark .hljs-quote, +html.dark .hljs-attr, +html.dark .hljs-attribute { color: #5EFBD7; } +html.dark .hljs-number, +html.dark .hljs-meta { color: #FF9E9E; } +html.dark .hljs-comment { color: #9AA7A7; font-style: italic; } + +/* The editable cells replace JupyterLite's power button on every page. Hide + the whole toolbar wrapper, not just the button: the wrapper otherwise + leaves a small ghost dot. */ +.myst-jp-nb-toolbar { display: none !important; } diff --git a/python/src/nma_kit/branding.py b/python/src/nma_kit/branding.py new file mode 100644 index 0000000..bcf4a56 --- /dev/null +++ b/python/src/nma_kit/branding.py @@ -0,0 +1,161 @@ +"""Site branding: the kit's stylesheet, logos, favicon and fonts. + +`nma-kit sync-assets` (also run by `prepare-site`) writes them to `_nma/` in +the course. The course's myst.yml points MyST at the files: + + site: + options: + style: _nma/site.css + logo: _nma/logo.svg + logo_dark: _nma/logo-dark.svg + favicon: _nma/favicon.png + +and may adjust them in the `nma:` block: + + nma: + brand: # all optional, "#RRGGBB" + primary: "#008CBF" # links, accents, buttons + primary_hover: "#006B8F" # default: primary, darkened + primary_light: "#35B4DC" # accent on dark backgrounds, default: primary, lightened + secondary: "#45429C" # header surface, tips + accent: "#FA7A7A" # exercise cells + style: + extra: _static/extra.css # the course's own CSS, appended after the kit's + +`_nma/site.css` is the kit stylesheet (Rubik embedded as data URIs, so no +request leaves the site), then the brand overrides, then the course's CSS: +MyST accepts a single `style` file. The folder gets its own .gitignore, so +courses never commit it. +""" + +import base64 +import re +import shutil +from pathlib import Path + +from nma_kit.config import Settings + +ASSETS = Path(__file__).parent / "assets" +OUT_DIR = Path("_nma") + +BRAND_KEYS = ("primary", "primary_hover", "primary_light", "secondary", "accent") +HEX_RE = re.compile(r"^#[0-9A-Fa-f]{6}$") + +# (weight, style, file) of the embedded Rubik faces +FONTS = ( + ("400", "normal", "Rubik-400-normal.woff2"), + ("400", "italic", "Rubik-400-italic.woff2"), + ("500", "normal", "Rubik-500-normal.woff2"), + ("700", "normal", "Rubik-700-normal.woff2"), +) +FONT_PLACEHOLDER = "/*FONTS*/" + +# kit asset -> file in _nma/ (the names courses use in myst.yml) +COPIED = {"logo.svg": "logo.svg", "logo-dark.svg": "logo-dark.svg", "favicon.png": "favicon.png"} + + +class BrandingError(Exception): + """A problem with the `nma.brand` / `nma.style` settings.""" + + +def _section(nma: dict, name: str) -> dict: + section = nma.get(name) + return section if isinstance(section, dict) else {} + + +def brand_errors(nma: dict) -> list[str]: + """Problems with `nma.brand` and `nma.style.extra` (the extra file's existence + is checked at sync time, relative to the course root).""" + errors = [] + brand = _section(nma, "brand") + for key, value in brand.items(): + if key not in BRAND_KEYS: + errors.append(f"myst.yml: nma.brand.{key} is not a known colour " + f"(one of {', '.join(BRAND_KEYS)})") + elif not isinstance(value, str) or not HEX_RE.match(value): + errors.append(f"myst.yml: nma.brand.{key} must be a colour like \"#008CBF\", got {value!r}") + for key in _section(nma, "style"): + if key != "extra": + errors.append(f"myst.yml: nma.style.{key} is not a known setting (only `extra`)") + return errors + + +def _mix(color: str, other: tuple[int, int, int], share: float) -> str: + """`color` with `share` (0..1) of `other` mixed in, as #RRGGBB.""" + rgb = [int(color[i:i + 2], 16) for i in (1, 3, 5)] + mixed = [round(c * (1 - share) + o * share) for c, o in zip(rgb, other, strict=True)] + return "#" + "".join(f"{v:02X}" for v in mixed) + + +def brand_overrides(nma: dict) -> str: + """A `:root { ... }` block for the colours the course set, or ''. + + Setting only `primary` also derives `primary_hover` (25% darker) and + `primary_light` (35% lighter), because those two follow the primary + colour unless the course says otherwise. + """ + brand = {k: v.upper() for k, v in _section(nma, "brand").items() if k in BRAND_KEYS} + if "primary" in brand: + brand.setdefault("primary_hover", _mix(brand["primary"], (0, 0, 0), 0.25)) + brand.setdefault("primary_light", _mix(brand["primary"], (255, 255, 255), 0.35)) + if not brand: + return "" + lines = [f" --nma-brand-{key.replace('_', '-')}: {brand[key]};" for key in BRAND_KEYS if key in brand] + return "/* Course colours (nma.brand in myst.yml) */\n:root {\n" + "\n".join(lines) + "\n}\n" + + +def font_face_css() -> str: + rules = [] + for weight, style, name in FONTS: + data = base64.b64encode((ASSETS / "fonts" / name).read_bytes()).decode("ascii") + rules.append( + "@font-face {\n" + " font-family: 'Rubik';\n" + f" font-style: {style};\n" + f" font-weight: {weight};\n" + " font-display: swap;\n" + f" src: url(data:font/woff2;base64,{data}) format('woff2');\n" + "}" + ) + return "\n".join(rules) + + +def extra_css_path(settings: Settings) -> Path | None: + extra = _section(settings.nma, "style").get("extra") + return Path(extra) if isinstance(extra, str) and extra.strip() else None + + +def build_css(settings: Settings, root: Path = Path(".")) -> str: + """The complete site stylesheet for a course.""" + base = (ASSETS / "site.css").read_text(encoding="utf-8") + if FONT_PLACEHOLDER not in base: + raise BrandingError("kit stylesheet has no font placeholder (broken install?)") + parts = [base.replace(FONT_PLACEHOLDER, font_face_css())] + overrides = brand_overrides(settings.nma) + if overrides: + parts.append(overrides) + extra = extra_css_path(settings) + if extra is not None: + path = root / extra + if not path.is_file(): + raise BrandingError(f"nma.style.extra: {extra.as_posix()} not found (relative to the course root)") + parts.append(f"/* Course CSS ({extra.as_posix()}) */\n" + path.read_text(encoding="utf-8")) + return "\n".join(part.rstrip("\n") + "\n" for part in parts) + + +def sync_assets(settings: Settings, root: Path = Path(".")) -> list[Path]: + """Write the branding files to `/_nma/`. Returns the files written.""" + errors = brand_errors(settings.nma) + if errors: + raise BrandingError("; ".join(errors)) + css = build_css(settings, root) # before touching the disk, so a bad setting leaves it alone + out = root / OUT_DIR + out.mkdir(exist_ok=True) + (out / ".gitignore").write_text("# written by `nma-kit sync-assets`\n*\n", encoding="utf-8") + written = [] + (out / "site.css").write_text(css, encoding="utf-8") + written.append(out / "site.css") + for source, name in COPIED.items(): + shutil.copyfile(ASSETS / source, out / name) + written.append(out / name) + return written diff --git a/python/src/nma_kit/cli.py b/python/src/nma_kit/cli.py index 254fa75..ccb6120 100644 --- a/python/src/nma_kit/cli.py +++ b/python/src/nma_kit/cli.py @@ -3,8 +3,10 @@ nma-kit check validate content and myst.yml nma-kit notebooks [--dry-run] generate student/instructor notebooks and solution files nma-kit render-scrolly pre-render scrolly page outputs into _build/scrolly/ + nma-kit sync-assets write the site branding (CSS, logos, favicon) to _nma/ nma-kit prepare-site add badges + fallback cells to the .md pages and pin the - kit URL in myst.yml (CI only, never commit the result) + kit URL in myst.yml, and run sync-assets (CI only, + never commit the result) nma-kit publish-notebooks push notebooks/ to the notebooks branch Exit codes: 0 ok, 1 content problems, 2 usage or configuration errors. @@ -16,7 +18,7 @@ from pathlib import Path from nma_kit import __version__ -from nma_kit import convert, scrolly +from nma_kit import branding, convert, scrolly from nma_kit.config import MYST_CONFIG, MissingConfig, read_settings from nma_kit.pin import pin_kit_url from nma_kit.publish import PublishError, publish_notebooks @@ -65,12 +67,20 @@ def cmd_render_scrolly(args) -> int: return scrolly.render_all(read_settings().tutorials_dir) +def cmd_sync_assets(args) -> int: + settings = read_settings() + for path in branding.sync_assets(settings): + print(f" wrote {path.as_posix()}") + return 0 + + def cmd_prepare_site(args) -> int: settings = _settings() if not _validate(check_injected=False): return 1 count = convert.process_tutorials(settings, args.dry_run, prepare_site=True) if not args.dry_run: + branding.sync_assets(settings) text = MYST_CONFIG.read_text(encoding="utf-8") pinned = pin_kit_url(text, args.kit_version) if pinned != text: @@ -105,6 +115,8 @@ def build_parser() -> argparse.ArgumentParser: sub.add_parser("render-scrolly", help="pre-render scrolly pages").set_defaults(func=cmd_render_scrolly) + sub.add_parser("sync-assets", help="write the site branding to _nma/").set_defaults(func=cmd_sync_assets) + p = sub.add_parser("prepare-site", help="prepare the .md pages for `myst build` (CI only)") p.add_argument("--dry-run", action="store_true") p.add_argument("--kit-version", default=__version__, @@ -126,7 +138,7 @@ def main(argv: list[str] | None = None) -> int: return 2 try: return args.func(args) - except (MissingConfig, UsageError) as exc: + except (MissingConfig, UsageError, branding.BrandingError) as exc: print(f"nma-kit: {exc}", file=sys.stderr) return 2 except PublishError as exc: diff --git a/python/src/nma_kit/convert.py b/python/src/nma_kit/convert.py index e393d0a..99ef147 100644 --- a/python/src/nma_kit/convert.py +++ b/python/src/nma_kit/convert.py @@ -67,8 +67,10 @@ Settings, load_settings, page_location, + read_nma_block, read_settings, ) +from nma_kit import branding as nma_branding from nma_kit import feedback as nma_feedback from nma_kit import scrolly as nma_scrolly from nma_kit import solutions as nma_solutions @@ -173,6 +175,7 @@ def validate_content(check_injected: bool = True) -> list[str]: tutorials_dir = Path(load_settings(config_text).tutorials_dir) config_problems = nma_feedback.config_errors(config_text) errors += config_problems + errors += nma_branding.brand_errors(read_nma_block(config_text)) has_config = not config_problems and nma_feedback.load_config(config_text) is not None for path in sorted(tutorials_dir.rglob("*.ipynb")): if ".ipynb_checkpoints" in path.parts: diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 9fbc3b1..585a5a4 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -8,6 +8,9 @@ loads require.js from cdnjs; in sandboxes where cdnjs is blocked, set this to serve it locally. Service workers are then blocked, because Playwright can't intercept requests that go through JupyterLite's service worker. + +NMA_E2E_FRESH_CONTEXT=1 (optional): a new browser context per test instead of one +shared by the whole run (slower: every test downloads Pyodide again). """ import os @@ -39,21 +42,40 @@ def _browser(): browser.close() -@pytest.fixture -def page(_browser): +def _new_context(browser): options = {"ignore_https_errors": True} if REQUIREJS: options["service_workers"] = "block" - context = _browser.new_context(**options) + context = browser.new_context(**options) if REQUIREJS: context.route( "**/require.min.js", lambda route: route.fulfill(path=REQUIREJS, content_type="application/javascript"), ) + return context + + +@pytest.fixture(scope="session") +def _shared_context(_browser): + """One context for the whole run. Pages in a context share the HTTP cache, so + Pyodide and its packages are downloaded once, not once per test (the slow + part of this suite). Set NMA_E2E_FRESH_CONTEXT=1 for a new context per test, + to rule out state leaking between tests.""" + context = _new_context(_browser) + yield context + context.close() + + +@pytest.fixture +def page(_browser, _shared_context): + fresh = os.environ.get("NMA_E2E_FRESH_CONTEXT") == "1" + context = _new_context(_browser) if fresh else _shared_context page = context.new_page() page.set_default_timeout(120_000) yield page - context.close() + page.close() + if fresh: + context.close() @pytest.fixture diff --git a/tests/e2e/test_branding_browser.py b/tests/e2e/test_branding_browser.py new file mode 100644 index 0000000..eb3eaf7 --- /dev/null +++ b/tests/e2e/test_branding_browser.py @@ -0,0 +1,55 @@ +"""Browser checks for the kit's site branding (_nma/site.css via `nma-kit sync-assets`).""" + +import pytest + +pytest.importorskip("playwright") + +# Deterministic checks: a failure is not cold-cache flakiness, so don't rerun it. +pytestmark = pytest.mark.flaky(reruns=0) + +PAGE = "/tutorials/w2d1-examples/w2d1-tutorial1/" +GOOGLE_FONTS = ("fonts.googleapis.com", "fonts.gstatic.com") + + +def load(page, base_url): + """Open the page; return the font requests it made as (url, referer, resource type).""" + fonts = [] + + def record(request): + if any(host in request.url for host in GOOGLE_FONTS): + fonts.append((request.url, request.headers.get("referer", "?"), request.resource_type)) + + page.on("request", record) + page.goto(base_url + PAGE) + page.wait_for_load_state("networkidle") + return fonts + + +def test_brand_font_and_colours_apply(page, base_url): + fonts = load(page, base_url) + + # Rubik comes from the embedded data URI, so it must be loaded and in use. + assert page.evaluate("document.fonts.ready.then(() => document.fonts.check('1em Rubik'))") + assert "Rubik" in page.evaluate("getComputedStyle(document.body).fontFamily") + + primary = page.evaluate( + "getComputedStyle(document.documentElement).getPropertyValue('--nma-brand-primary').trim()") + assert primary.upper() == "#008CBF" + + # What the kit controls: Rubik is never fetched from Google. + assert not [f for f in fonts if "rubik" in f[0].lower()], fonts + + +def test_no_google_font_requests(page, base_url): + """The kit's stylesheet makes no font requests. Anything reported here comes + from something else on the page (the theme, thebe, ...): the Referer is the + stylesheet that asked for it.""" + fonts = load(page, base_url) + assert not fonts, "\n".join(f"{kind}: {url}\n asked by {referer}" for url, referer, kind in sorted(set(fonts))) + + +def test_favicon_and_logo_come_from_the_kit(page, base_url): + page.goto(base_url + PAGE) + page.wait_for_load_state("networkidle") + assert page.locator("link[rel~='icon']").count() >= 1 + assert page.locator("header img, nav img").count() >= 1 diff --git a/tests/example-course/_static/custom.css b/tests/example-course/_static/custom.css index 5fd0118..7432654 100644 --- a/tests/example-course/_static/custom.css +++ b/tests/example-course/_static/custom.css @@ -1,5 +1,2 @@ -/* Required by the course kit: editable cells replace JupyterLite's power - button site-wide (a plugin can't add site-wide CSS, so every course keeps - this rule in its own stylesheet). Hide the whole toolbar wrapper, not just - the power button: the wrapper otherwise leaves a small ghost dot. */ -.myst-jp-nb-toolbar { display: none !important; } +/* Course-specific CSS, appended after the kit's stylesheet (nma.style.extra in + myst.yml). The kit already carries the brand and the toolbar rule. */ diff --git a/tests/example-course/myst.yml b/tests/example-course/myst.yml index c8775f8..2c126f7 100644 --- a/tests/example-course/myst.yml +++ b/tests/example-course/myst.yml @@ -24,10 +24,17 @@ site: template: book-theme options: folders: true # URLs keep the folder, like the template (/tutorials/w2d1-examples/...) - style: _static/custom.css # holds the toolbar rule every course needs + # Written by `nma-kit sync-assets` (prepare-site runs it in CI). + style: _nma/site.css + logo: _nma/logo.svg + logo_dark: _nma/logo-dark.svg + logo_text: Course Kit Example Course + favicon: _nma/favicon.png # Course kit settings (read by the kit's plugin and the nma-kit CLI). nma: + style: + extra: _static/custom.css # the course's own CSS, appended after the kit's feedback: url: https://pmyvdlilci.execute-api.us-east-1.amazonaws.com/klab project: neuromatch_cn diff --git a/tests/js/scrolly.test.mjs b/tests/js/scrolly.test.mjs index 9c279c3..6273703 100644 --- a/tests/js/scrolly.test.mjs +++ b/tests/js/scrolly.test.mjs @@ -314,3 +314,42 @@ test('cards stay in their column when they hold wide content (code blocks)', () assert.ok(pre, 'no .scrolly-card pre rule'); assert.match(pre.body, /overflow-x:\s*auto/); }); + +// ---- colours: light and dark mode share one token set ----------------------- + +const STYLE_DIR = join(here, '..', '..', 'js', 'src', 'scrolly', 'assets'); +const cssFiles = ['scrolly.css', 'runnable.css', 'engine.mjs'].map((f) => readFileSync(join(STYLE_DIR, f), 'utf8')); +const layoutCss = cssFiles[0]; +const block = (selector) => { + const start = layoutCss.indexOf(`${selector} {`); + assert.ok(start >= 0, `${selector} block exists`); + return layoutCss.slice(start, layoutCss.indexOf('\n}', start)); +}; +const defined = (css) => new Set([...css.matchAll(/^\s*(--sc-[\w-]+):/gm)].map((m) => m[1])); + +test('every --sc-* token used by the scrolly styles is defined on .scrolly', () => { + const light = defined(block('.scrolly')); + const used = new Set(cssFiles.flatMap((css) => [...css.matchAll(/var\((--sc-[\w-]+)/g)].map((m) => m[1]))); + const undefinedTokens = [...used].filter((t) => !light.has(t)); + assert.deepEqual(undefinedTokens, []); +}); + +test('dark mode only overrides tokens that exist in light mode, and covers the surfaces', () => { + const light = defined(block('.scrolly')); + const dark = defined(block('.dark .scrolly')); + assert.deepEqual([...dark].filter((t) => !light.has(t)), []); + for (const surface of ['--sc-bg', '--sc-text', '--sc-card-bg', '--sc-card-text', '--sc-code-bg', '--sc-code-text', '--sc-line']) { + assert.ok(dark.has(surface), `dark mode sets ${surface}`); + } +}); + +test('scrolly styles use the kit brand variables, with the Neuromatch defaults as fallbacks', () => { + const light = block('.scrolly'); + for (const v of ['--nma-brand-primary', '--nma-brand-primary-light', '--nma-brand-secondary', '--nma-brand-accent']) { + assert.match(light, new RegExp(`var\\(${v}, #[0-9a-f]{6}\\)`), `${v} has a fallback`); + } +}); + +test('the old fixed indigo palette is gone', () => { + for (const css of cssFiles) assert.doesNotMatch(css, /--nm-(indigo|mint|salmon|cream|lilac|ink|purple|blue|text|muted)\b/); +}); diff --git a/tests/python/test_branding.py b/tests/python/test_branding.py new file mode 100644 index 0000000..aa32fe5 --- /dev/null +++ b/tests/python/test_branding.py @@ -0,0 +1,153 @@ +import re + +import pytest + +from nma_kit import branding +from nma_kit.cli import main +from nma_kit.config import load_settings + +MYST = "version: 1\nproject:\n github: https://github.com/org/repo\n" + + +def settings(nma_block: str = ""): + return load_settings(MYST + nma_block) + + +def test_packaged_assets_exist(): + for name in ("site.css", "logo.svg", "logo-dark.svg", "favicon.png", "fonts/OFL.txt"): + assert (branding.ASSETS / name).is_file(), name + for _, _, name in branding.FONTS: + assert (branding.ASSETS / "fonts" / name).is_file(), name + + +def test_favicon_is_small(): + assert (branding.ASSETS / "favicon.png").stat().st_size < 50_000 + + +def test_css_embeds_fonts_and_makes_no_external_requests(): + css = branding.build_css(settings()) + assert css.count("@font-face") == len(branding.FONTS) + assert css.count("data:font/woff2;base64,") == len(branding.FONTS) + assert branding.FONT_PLACEHOLDER not in css + assert "googleapis" not in css + assert not re.search(r"@import|url\(\s*['\"]?https?:", css) + + +def test_css_keeps_the_toolbar_rule_courses_used_to_carry(): + assert ".myst-jp-nb-toolbar { display: none !important; }" in branding.build_css(settings()) + + +def test_no_brand_block_means_no_overrides(): + assert branding.brand_overrides({}) == "" + assert "Course colours" not in branding.build_css(settings()) + + +def test_brand_overrides_follow_the_primary_colour(): + css = branding.brand_overrides({"brand": {"primary": "#ff0000"}}) + assert "--nma-brand-primary: #FF0000;" in css + assert "--nma-brand-primary-hover: #BF0000;" in css # 25% darker + assert "--nma-brand-primary-light: #FF5959;" in css # 35% lighter + + +def test_explicit_hover_and_light_win_over_derived(): + css = branding.brand_overrides({"brand": {"primary": "#112233", "primary_hover": "#000000", + "primary_light": "#FFFFFF", "accent": "#abcdef"}}) + assert "--nma-brand-primary-hover: #000000;" in css + assert "--nma-brand-primary-light: #FFFFFF;" in css + assert "--nma-brand-accent: #ABCDEF;" in css + + +def test_overrides_come_after_the_base_so_they_win(): + css = branding.build_css(settings('nma:\n brand:\n primary: "#ff0000"\n')) + assert css.index("--nma-brand-primary: #008CBF") < css.index("--nma-brand-primary: #FF0000") + + +def test_secondary_only_does_not_touch_primary(): + css = branding.brand_overrides({"brand": {"secondary": "#123456"}}) + assert "--nma-brand-secondary: #123456;" in css and "primary" not in css + + +@pytest.mark.parametrize("brand, message", [ + ({"primary": "red"}, "must be a colour"), + ({"primary": "#fff"}, "must be a colour"), + ({"primry": "#ffffff"}, "not a known colour"), +]) +def test_brand_errors(brand, message): + errors = branding.brand_errors({"brand": brand}) + assert len(errors) == 1 and message in errors[0] + + +def test_unknown_style_setting_is_an_error(): + assert "nma.style.css" in branding.brand_errors({"style": {"css": "x.css"}})[0] + + +def test_extra_css_is_appended_last(tmp_path): + (tmp_path / "extra.css").write_text("h1 { color: hotpink; }\n") + css = branding.build_css(settings("nma:\n style:\n extra: extra.css\n"), tmp_path) + assert css.rstrip().endswith("h1 { color: hotpink; }") + assert css.index("toolbar") < css.index("hotpink") + + +def test_missing_extra_css_is_an_error(tmp_path): + with pytest.raises(branding.BrandingError, match=r"extra\.css not found"): + branding.build_css(settings("nma:\n style:\n extra: extra.css\n"), tmp_path) + + +def test_sync_writes_the_files_the_course_references(tmp_path): + written = branding.sync_assets(settings(), tmp_path) + names = {p.name for p in written} + assert names == {"site.css", "logo.svg", "logo-dark.svg", "favicon.png"} + assert all(p.parent == tmp_path / "_nma" for p in written) + assert (tmp_path / "_nma" / ".gitignore").read_text().splitlines()[-1] == "*" + + +def test_sync_is_repeatable(tmp_path): + first = branding.sync_assets(settings(), tmp_path)[0].read_text() + assert branding.sync_assets(settings(), tmp_path)[0].read_text() == first + + +def test_bad_settings_write_nothing(tmp_path): + with pytest.raises(branding.BrandingError): + branding.sync_assets(settings('nma:\n brand:\n primary: "red"\n'), tmp_path) + assert not (tmp_path / "_nma").exists() + + +# --- through the CLI -------------------------------------------------------- + +def course(tmp_path, nma_block=""): + (tmp_path / "myst.yml").write_text(MYST + nma_block) + (tmp_path / "tutorials").mkdir() + return tmp_path + + +def test_cli_sync_assets(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(course(tmp_path)) + assert main(["sync-assets"]) == 0 + assert (tmp_path / "_nma" / "site.css").is_file() + assert "_nma/site.css" in capsys.readouterr().out + + +def test_cli_sync_assets_reports_bad_settings(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(course(tmp_path, 'nma:\n brand:\n primary: "red"\n')) + assert main(["sync-assets"]) == 2 + assert "nma.brand.primary" in capsys.readouterr().err + assert not (tmp_path / "_nma").exists() + + +def test_cli_check_rejects_bad_brand(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(course(tmp_path, 'nma:\n brand:\n primary: "red"\n')) + assert main(["check"]) == 1 + assert "nma.brand.primary" in capsys.readouterr().err + + +def test_cli_check_accepts_good_brand(tmp_path, monkeypatch): + monkeypatch.chdir(course(tmp_path, 'nma:\n brand:\n primary: "#112233"\n')) + assert main(["check"]) == 0 + + +def test_prepare_site_syncs_assets_but_dry_run_does_not(tmp_path, monkeypatch): + monkeypatch.chdir(course(tmp_path)) + assert main(["prepare-site", "--dry-run"]) == 0 + assert not (tmp_path / "_nma").exists() + assert main(["prepare-site"]) == 0 + assert (tmp_path / "_nma" / "site.css").is_file()