diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 13fc307..7b90652 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -1,8 +1,16 @@
-name: Deploy to GitHub Pages
+name: Validate and deploy
-# Publishes a copy of the deck with speaker notes stripped (see
-# tools/strip-notes.py), so the live site never exposes the S speaker view
-# notes. The repo keeps the notes; only the deployed artifact is stripped.
+# Validation runs on every pull request and push to main; deployment happens
+# only from main, and only after validation succeeds.
+#
+# build publishes an ALLOWLISTED, notes-free copy of the site (see
+# tools/strip-notes.py) plus generated extras: a slides.pdf, a social-card.png
+# and an offline.zip per deck. PDFs/cards are cached and regenerated only when
+# the deck or the shared engine changed (content-hash check inside
+# tools/export-pdf.mjs).
+#
+# All actions used here are first-party (actions/*), referenced by major
+# version tag. If supply-chain policy tightens, pin each to a full commit SHA.
#
# One-time setup: repo Settings → Pages → Build and deployment →
# Source = "GitHub Actions" (instead of "Deploy from a branch").
@@ -10,6 +18,7 @@ name: Deploy to GitHub Pages
on:
push:
branches: [main]
+ pull_request:
workflow_dispatch:
permissions:
@@ -19,24 +28,158 @@ permissions:
# Allow one concurrent deployment; let an in-progress run finish.
concurrency:
- group: pages
+ group: pages-${{ github.ref }}
cancel-in-progress: false
jobs:
+ validate:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Static checks (audit, strip-notes tests, landing-page sync)
+ run: |
+ python3 tools/test_strip_notes.py
+ python3 tools/build-index.py --check
+ python3 tools/audit.py --strict
+ python3 tools/strip-notes.py /tmp/site-check
+ python3 tools/audit.py --site /tmp/site-check --strict
+
+ - uses: actions/setup-node@v6
+ with:
+ node-version: 22
+
+ - name: Install Playwright
+ run: |
+ npm install --no-save playwright
+ npx playwright install --with-deps chromium
+
+ - name: Browser checks (1280×720, 844×390, 390×844)
+ run: node tools/browser-check.mjs
+
+ # Lightweight visual regression: only when the shared engine changed on a
+ # PR — capture the component catalogue from the merge-base and from the
+ # head, compare with a loose tolerance, and upload diff images as artifacts
+ # on failure. Skipped when the catalogue deck itself changed in the same
+ # PR: the baseline then shows different *content*, so a pixel comparison
+ # can only measure the intended edit, not engine drift.
+ visual:
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ if: github.event_name == 'pull_request'
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+
+ - name: Check whether the shared engine changed (and the catalogue did not)
+ id: engine
+ run: |
+ BASE=$(git merge-base origin/${{ github.base_ref }} HEAD)
+ CHANGED=$(git diff --name-only "$BASE" HEAD)
+ engine=false; catalogue=false
+ echo "$CHANGED" | grep -qE '^shared/(theme\.css|deck\.js|reveal/)' && engine=true
+ echo "$CHANGED" | grep -qE '^talks/(_showcase|_template)/' && catalogue=true
+ if [ "$engine" = true ] && [ "$catalogue" = false ]; then
+ echo "changed=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "changed=false" >> "$GITHUB_OUTPUT"
+ [ "$engine" = true ] && echo "::notice::visual regression skipped — the catalogue deck changed in this PR, so the merge-base screenshots show different content"
+ fi
+ echo "base=$BASE" >> "$GITHUB_OUTPUT"
+
+ - uses: actions/setup-node@v6
+ if: steps.engine.outputs.changed == 'true'
+ with:
+ node-version: 22
+
+ - name: Install Playwright
+ if: steps.engine.outputs.changed == 'true'
+ run: |
+ npm install --no-save playwright pixelmatch pngjs
+ npx playwright install --with-deps chromium
+
+ - name: Capture merge-base screenshots
+ if: steps.engine.outputs.changed == 'true'
+ run: |
+ git worktree add /tmp/base-tree ${{ steps.engine.outputs.base }}
+ node tools/browser-check.mjs --root /tmp/base-tree --decks _template --screenshots /tmp/shots-base || true
+
+ - name: Capture head screenshots
+ if: steps.engine.outputs.changed == 'true'
+ run: node tools/browser-check.mjs --decks _template --screenshots /tmp/shots-head
+
+ - name: Compare
+ if: steps.engine.outputs.changed == 'true'
+ run: node tools/visual-diff.mjs --before /tmp/shots-base --after /tmp/shots-head --out /tmp/visual-diff
+
+ - name: Upload diff images
+ if: failure()
+ uses: actions/upload-artifact@v6
+ with:
+ name: visual-diff
+ path: |
+ /tmp/visual-diff
+ /tmp/shots-base
+ /tmp/shots-head
+ if-no-files-found: ignore
+
build:
+ needs: validate
runs-on: ubuntu-latest
+ timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/configure-pages@v6
- - name: Strip speaker notes into ./_site
- run: python3 tools/strip-notes.py _site
+
+ - name: Build the allowlisted, notes-free site
+ run: |
+ python3 tools/strip-notes.py _site
+ python3 tools/audit.py --site _site
+
+ - uses: actions/setup-node@v6
+ with:
+ node-version: 22
+
+ - name: Install Playwright
+ run: |
+ npm install --no-save playwright
+ npx playwright install --with-deps chromium
+
+ # PDFs + social cards are expensive: restore the last run's outputs and
+ # let export-pdf's content-hash check skip every unchanged deck.
+ - name: Restore PDF/social-card cache
+ uses: actions/cache@v5
+ with:
+ path: .extras-cache
+ key: deck-extras-${{ hashFiles('talks/**', 'shared/**') }}
+ restore-keys: deck-extras-
+
+ - name: Export PDFs and social cards
+ run: |
+ if [ -d .extras-cache ]; then cp -r .extras-cache/talks/. _site/talks/ 2>/dev/null || true; fi
+ node tools/export-pdf.mjs --root _site
+ mkdir -p .extras-cache
+ rm -rf .extras-cache/talks
+ (cd _site && find talks -maxdepth 2 \
+ \( -name slides.pdf -o -name social-card.png -o -name .extras-hash \) \
+ -exec install -D {} ../.extras-cache/{} \;)
+
+ - name: Build offline bundles
+ run: |
+ python3 tools/build-offline.py --root _site
+ find _site -name .extras-hash -delete
+
- uses: actions/upload-pages-artifact@v5
with:
path: _site
deploy:
needs: build
+ if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
+ timeout-minutes: 10
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
diff --git a/.gitignore b/.gitignore
index e59879d..1f02634 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,12 @@ __pycache__/
# Claude Code (local preview dev-server config, not shared)
.claude/launch.json
+
+# Local build previews + CI-installed browser-check dependencies
+_site/
+node_modules
+package.json
+package-lock.json
+
+# CI cache for generated PDFs/social cards
+.extras-cache/
diff --git a/404.html b/404.html
new file mode 100644
index 0000000..a037767
--- /dev/null
+++ b/404.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+ Page not found — Slides · Frédérick Madore
+
+
+
+
+
+
+
+
Slides · Frédérick Madore
+
Not found
+
+
This page doesn't exist — the talk may have moved or the address has a typo.
+ Back to all talks
+
+
+
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..7e2f2a8
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,52 @@
+# Licence
+
+This repository mixes code, scholarly content and third-party material, each
+with its own status.
+
+## Code — MIT
+
+The original code in this repository — the shared engine (`shared/deck.js`,
+`shared/theme.css`), the landing page, the build and validation tooling under
+`tools/`, and `serve-deck.py` — is released under the MIT License:
+
+> Copyright (c) 2026 Frédérick Madore
+>
+> Permission is hereby granted, free of charge, to any person obtaining a copy
+> of this software and associated documentation files (the "Software"), to deal
+> in the Software without restriction, including without limitation the rights
+> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+> copies of the Software, and to permit persons to whom the Software is
+> furnished to do so, subject to the following conditions:
+>
+> The above copyright notice and this permission notice shall be included in
+> all copies or substantial portions of the Software.
+>
+> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+> THE SOFTWARE.
+
+## Slide text and original figures — CC BY 4.0
+
+The scholarly content of the presentations under `talks/` — slide text,
+speaker notes and figures authored by the presenters — is licensed under
+[Creative Commons Attribution 4.0 International (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/).
+Please attribute **Frédérick Madore** (and the named co-presenter of the talk,
+where applicable) with a link to .
+
+## What these licences do NOT cover
+
+- **Vendored software** (reveal.js, highlight.js) and the **fonts**
+ (EB Garamond, Libre Franklin) keep their own licences — see
+ [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
+- **Institutional logos** (Africa Multiple Cluster of Excellence, University
+ of Bayreuth, King's College London, ORCID, GitHub, LinkedIn, MCP) remain the
+ marks of their owners and are used for identification only.
+- **Externally sourced images** in `talks/*/assets/` and `shared/assets/`
+ (digitised newspaper pages, archival photographs, screenshots of external
+ websites and reports) remain under their original rights; their sources are
+ credited on the slides where they appear. They are reproduced here for
+ scholarly presentation and are not covered by the CC BY grant above.
diff --git a/README.md b/README.md
index b73e0ee..b16ab44 100644
--- a/README.md
+++ b/README.md
@@ -44,21 +44,35 @@ iteration this theme grew out of — live in [`.impeccable.md`](.impeccable.md).
```
slides/
-├── index.html ← landing page (lists talks — edit the TALKS array)
+├── index.html ← landing page (GENERATED from talks/talks.json)
├── shared/ ← the reusable ENGINE, one copy shared by every talk
│ ├── theme.css deck.js the “Broadsheet” theme + nav / chrome / TOC script
-│ ├── logo-*.{png,webp,svg} Africa Multiple, Bayreuth, KCL marks
+│ ├── highlight.min.js slim vendored highlight.js (see vendor-manifest.json)
+│ ├── logo-*.{png,svg} Africa Multiple, Bayreuth, KCL marks
+│ ├── assets/ images used by more than one talk
│ ├── fonts/ self-hosted EB Garamond + Libre Franklin (offline)
│ └── reveal/ vendored reveal.js v6 + plugins (offline)
├── talks/
-│ ├── _template/ ← copy this to start a new talk
+│ ├── talks.json ← the TALK MANIFEST (one record per published talk)
+│ ├── _template/ minimal starter (tools/new-talk.py copies this)
+│ ├── _showcase/ the full layout catalogue (never published)
│ └── YYYY-MM-DD-place-title/
│ ├── index.html (slides + DECK_CONFIG; points to ../../shared)
│ └── assets/ (this talk's images / embedded files)
-├── tools/ strip-notes.py · fetch-highlight.py (build helpers)
+├── tools/
+│ ├── new-talk.py scaffold a talk (slug, metadata, manifest, QR)
+│ ├── build-index.py render the landing page + sitemap from the manifest
+│ ├── audit.py one-command repository audit (static checks)
+│ ├── browser-check.mjs Playwright checks at 3 viewport sizes
+│ ├── visual-diff.mjs tolerant screenshot comparison (CI)
+│ ├── export-pdf.mjs per-deck slides.pdf + social-card.png (CI)
+│ ├── build-offline.py per-deck offline.zip bundles (CI)
+│ ├── strip-notes.py allowlisted, notes-free publication build (+ tests)
+│ └── fetch-highlight.py regenerate the slim highlight.js bundle
├── serve-deck.py ← no-cache dev server (serves the whole repo)
-├── .github/workflows/ pages.yml — deploy to GitHub Pages (notes stripped)
-└── .nojekyll CNAME .impeccable.md README.md
+├── .github/workflows/ pages.yml — validate, build and deploy
+└── .nojekyll CNAME 404.html robots.txt sitemap.xml LICENSE.md
+ THIRD_PARTY_NOTICES.md .impeccable.md README.md
```
Each talk references the one shared engine via `../../shared/…`, so a fix to `theme.css`
@@ -70,15 +84,25 @@ redirects to `slides.frederickmadore.com`.
## Add a new talk
-1. **Copy the starter:** `talks/_template` → `talks/YYYY-MM-DD-place-short-title`.
-2. Edit the **`DECK_CONFIG`** block (presenter, title, venue, links) and the slides.
-3. Add one entry to the **`TALKS`** array in [`index.html`](index.html) (newest first):
+One command scaffolds everything:
-```js
-{ date: "2026-09-01", event: "Conference · City", title: "My talk",
- desc: "One-line description.", slug: "2026-09-01-city-my-talk" }
+```bash
+python3 tools/new-talk.py \
+ --date 2026-09-01 --place bayreuth \
+ --title "My talk" --venue "Conference · Bayreuth · 1 Sept 2026" \
+ --event "Conference · Bayreuth" --desc "One-line description." --lang en
```
+It copies `talks/_template`, fills the metadata (DECK_CONFIG, cover, canonical
+URL + social metadata), registers the talk in [`talks/talks.json`](talks/talks.json),
+regenerates the landing page and writes a QR code pointing at the final URL
+(needs `pip install "qrcode[pil]"` once — everything else works without it).
+Then edit the slides; the full layout catalogue with copy-paste examples lives
+in `talks/_showcase/` (preview at `/talks/_showcase/`).
+
+Doing it by hand instead: copy `talks/_template`, edit `DECK_CONFIG` + slides,
+add an entry to `talks/talks.json` and run `python3 tools/build-index.py`.
+
---
## Preview locally
@@ -89,8 +113,9 @@ Decks must be served over HTTP (not `file://`). From the repo root:
python serve-deck.py # no-cache server → http://localhost:8742
```
-Open `http://localhost:8742/` for the landing page, or a talk directly at
-`/talks//`. The no-cache server guarantees reloads always show your latest edits.
+Open `http://localhost:8742/` for the landing page (rendered statically from
+`talks/talks.json`, with client-side search and language/year/topic filters
+whose state lives in the URL), or a talk directly at `/talks//`. The no-cache server guarantees reloads always show your latest edits.
(For just viewing, any static server works, e.g. `python -m http.server`.)
---
@@ -134,8 +159,10 @@ control and the markup carries no stray form elements. See `talks/_template/inde
for a worked example of each layout.
**Scrollable file embed** (e.g. a GitHub skill): a `
` loads and syntax-highlights a vendored file you can
-scroll on stage. GitHub pages can't be `