Interactive lecture slides and self-check quizzes, served straight from GitHub Pages. One folder per lecture, two pages per lecture.
| Lecture | Slides | Quiz | Printable handout |
|---|---|---|---|
| 1 — How does a computer think? | /lecture1/slides | /lecture1/quiz | /lecture1/handout |
Every lecture follows the same pattern:
https://pgteach.github.io/curriculum/lectureN/slides
https://pgteach.github.io/curriculum/lectureN/quiz
https://pgteach.github.io/curriculum/lectureN/handout
curriculum/
├── lecture1/
│ ├── slides/index.html 12 slides, keyboard + swipe navigation
│ ├── quiz/index.html 10 questions, 4 sections, results recorded to a Sheet
│ └── handout/index.html 7-page A4 student booklet, print from the browser
├── templates/
│ ├── slides-template.html skeleton deck with the shared chrome
│ ├── quiz-template.html skeleton quiz with the shared engine
│ └── handout-template.html skeleton booklet with the print stylesheet
├── templates/blocks.html gallery: every slide layout, one per slide
├── scripts/
│ ├── new_lecture.py scaffolds lectureN/ from the templates
│ ├── check_lecture.py validates a lecture before you publish it
│ ├── test_pages.js runs every page in a fake browser
│ ├── protect_answers.py turns `a: 1` into an answer fingerprint
│ └── apps-script.gs the Apps Script that records quiz results
├── HANDOFF.md current state, open items, gotchas
└── README.md
Each index.html is fully self-contained — no build step, no bundler, no
shared asset folder. The only external requests are Google Fonts and the QR
image service, so a page dropped anywhere still works.
python scripts/new_lecture.py 2 "Variables & Data Types"
That creates lecture2/slides/index.html and lecture2/quiz/index.html with
the lecture number, title, page titles, QR code target and quiz payload label
all filled in, then verifies the result and prints the two live URLs.
Useful flags:
| Flag | Effect |
|---|---|
-t, --topic "…" |
course line on slide 1 (default: Programming & Artificial Intelligence) |
-a, --accent "#6A4C93" |
accent colour for the deck and handout (default: cycles by lecture number) |
-n, --dry-run |
report what would be written, write nothing |
-f, --force |
overwrite an existing lectureN/ |
Then fill in the content:
<section class="slide"> blocks in
lectureN/slides/index.html. Keep the QR slide last.SECTIONS and QUESTIONS at the top of the
<script> in lectureN/quiz/index.html.<section class="sheet"> per printed A4 page in
lectureN/handout/index.html.Check it before publishing:
python scripts/check_lecture.py 2
It fails on template text left in place, a QR pointing at the wrong lecture, an answer index off the end of its options, a section with no questions, JavaScript that will not parse, and a Liquid delimiter that would take the whole site down. Exit code is non-zero when anything is wrong, so it can gate a commit.
Hide the answers from the page source:
python scripts/protect_answers.py 2
Each a: 1 becomes k: "<fingerprint>". Re-run it after editing any
option’s text — if an option changes and its fingerprint does not, no
option matches and every answer counts as wrong. Both checks fail on
exactly that, and both run in CI, so it cannot reach students.
This stops the answers being read. It does not stop them being computed: the fingerprint function is in the page, so one line in the console still returns them. For a class that has not learned programming it is a real barrier; treat it as nothing more than that.
main.Both the options and the question order are shuffled at runtime, so you can write the correct answer in whatever slot reads most naturally. Options are reshuffled on every render; question order is reshuffled once per attempt, so two students sitting together are not on the same question at the same time.
Structure, typography and behaviour are shared on purpose — students should
not have to relearn where things are each week. What changes per lecture is
the accent colour, set once in LECTURE_ACCENT and picked up by the deck
and the handout: the progress bar, the eyebrow labels, the numbered steps, the
nav dots, the compare panels, the table headers and the handout’s header rule.
new_lecture.py cycles a palette by lecture number (teal, violet, blue, rose,
green, amber) or takes --accent "#RRGGBB". The quiz keeps the standard
palette — green and red mean right and wrong there, and that must not drift
lecture to lecture.
For more variety than colour, vary the layout blocks rather than the design. Open templates/blocks.html in a browser: it is a real deck with one slide per available layout, so you can see them all and copy the markup straight out of its source.
| Block | Use it when |
|---|---|
.pipe + .step |
a flow, read across |
.timeline + .tl |
a sequence, read down — better for four or more steps |
.vs + .pane |
two things, side by side |
.scale |
a spectrum, when the answer is “somewhere between” |
.statement |
one line, nothing else — the point you want to land |
.stats + .stat |
the figure is the point |
.check |
do this, not that |
.quote |
someone else’s words |
.kit + .card |
a small set of things, with icons |
table |
many rows, same shape |
.fields + .chip |
a list with no order |
class="slide dark" inverts any slide to navy — worth doing on the one or two
slides you most want remembered.
Grid thumbnails render at about 15vh. The files behind them are full
resolution, so the detail is already downloaded and simply not shown — from
the back of a room that is the difference between “that is a chip” and
reading i7-6700K off it. Clicking any photo opens it over the slide, with
its caption; the arrows walk the photos on that slide, Esc or a click on
the backdrop closes it. Nothing to add per photo: any figure.shot in a
.shots grid is picked up.
The deck’s own arrow keys and swipe carry an explicit zoomOpen() guard.
They have to: the lightbox and the navigation both listen on window, and
stopPropagation does not stop a sibling listener on the same target, so
without the guard pressing Right with a photo open advances the photo and
the slide behind it. check_lecture.py fails if any of the three guards goes
missing, and scripts/probe_lightbox.js checks the behaviour in a real
browser — a stubbed DOM cannot prove this one.
Slides are written into a fixed frame, so a dense one runs off the bottom — and nobody scrolls a projector, so the last lines are simply gone. Seven slides in lecture 2 were losing 32–90px that way.
Each slide is now measured when it opens and scaled down just enough to fit,
down to a floor of FIT_FLOOR = 0.72. Below that, type stops being readable
from the back of a room, so a slide that dense is left alone — that is a slide
to split, not to shrink. It re-measures on resize, on rotation, and after
fonts and images arrive.
Lecture 1 renders pixel-for-pixel identically: nothing there needed it.
The slide total, the nav dots, the progress bar, the n / total counter, the
question total, the per-section tracks and the QR code are all derived at
runtime from the DOM and the QUESTIONS array. Adding a slide or a question is
a one-place edit.
Each page carries a single LECTURE CONFIG block near the top of its
<script> — the lecture number, title and topic. Everything else on the page,
including both pgteach.github.io URLs, is built from it. That block is the
only thing new_lecture.py rewrites.
Slides are plain HTML using the classes already in the deck:
| Class | Use |
|---|---|
class="slide dark" |
inverted (navy) slide |
class="rise" |
reveal this child in sequence when the slide opens |
.eyebrow / h2 / .sub |
section label, headline, supporting line |
.arline .ar |
the Arabic explanation line (RTL, IBM Plex Sans Arabic) |
.pipe + .step |
numbered pipeline diagram |
.vs + .pane a / .pane b |
two-column compare |
.kit + .card |
icon row |
.fields + .chip |
tag cloud |
table |
comparison table |
.codeline .mono |
inline code chip |
Navigation: arrow keys, space, PageUp/PageDown, Home/End, the dots, or swipe.
The current slide is mirrored in the URL hash, so #7 deep-links to slide 7.
Open lectureN/handout in a browser and press Print this sheet, or
Ctrl/Cmd-P. The page is laid out in A4 with margins already set, one
<section class="sheet"> per sheet of paper, so what you see is what prints —
there is no separate PDF to keep in sync.
The page margin is deliberately 0, with the margins supplied by each sheet’s
own padding. Browsers can only draw their date / title / file-path / page-number
furniture inside the page margin, so with none there they print nothing of their
own. If a browser still adds them, switch off Headers and footers in the
print dialog.
The QR code on the last page is generated from the lecture number, and the page numbers in the footers are counted at runtime, so copying a sheet block or adding a page needs no renumbering and can never leave a stale link on paper. If the QR image fails to load, a red warning appears next to it — never print a sheet that shows it.
Reusable blocks: .stages (numbered row), table with td.k for the label
column, .term (gold rule), .fields/.chip (tag row), .write + .rule
(write-in line), ol.qs (numbered questions), .summary (highlighted box),
p.ar (Arabic line, RTL).
On submission the quiz POSTs JSON to the Apps Script endpoint set as
RESULTS_URL:
{
"lecture": "Lecture 1",
"name": "Ali Hassan Mohamed",
"phone": "01001234567",
"date": "2026-08-31",
"score": 8,
"total": 10,
"weak": "How computers work, Tools & mindset",
"sections": "How computers work: 1/2 | Programming & AI terms: 3/3 | Tools & mindset: 2/3 | Career fields: 2/2",
"wrongQuestions": [
{
"question": "What is the first stage in how a computer handles any task?",
"selected": "Processing",
"correct": "Input",
"why": "Everything starts with input — the computer receives data before it can do anything with it."
}
],
"image": "iVBORw0KGgoAAAANSUhEUgAA…"
}
wrongQuestions is an array — one entry per mistake, in the order they were
made. The same list is what the student sees under Questions to review on
the results screen.
image is a base64 PNG of the result card, drawn on a <canvas> from the same
data at the moment the student finishes — the student does not screenshot
anything. It is roughly 250 KB for a typical attempt. If the drawing fails for
any reason the field is "" and the submission still goes through; it is also
dropped if it somehow exceeds 4 MB.
The Apps Script decodes it into Drive under
PGteach quiz results / Lecture N / and writes the file link in the sheet’s
Screenshot column. Those files are deliberately left private to the account
that owns the script — they carry student names and phone numbers, so nothing in
the script shares them.
The request is sent with mode: "no-cors" (a browser cannot read a response
from Apps Script without CORS headers, and none are needed here) and
Content-Type: text/plain;charset=utf-8, which is the only JSON-carrying
content type a no-cors request is allowed to set. Apps Script reads the body
from e.postData.contents either way.
The receiving script lives in scripts/apps-script.gs.
It writes one tab per lecture (created on demand), flattens wrongQuestions
into a readable Mistakes column, and forces the Phone column to text so Sheets
does not read 01129907116 as a number and drop the leading zero.
To install it: open the Sheet, Extensions > Apps Script, paste the file in, then
Deploy > New deployment > Web app, Execute as: me, Who has access:
anyone, and paste the /exec URL into RESULTS_URL. After any later edit you
must deploy a new version — saving alone does not change the live URL.
Set RESULTS_URL = "" to turn sending off; the quiz still runs and still
shows the student their review list.
The questions are worded the way the textbook words them — several are copied
from its Worked Examples, and src: on each question records the page. That
includes the hard words: chronological order, empirical observation,
integrated circuit, approximately, escapes unintentionally, overlays,
appropriate description, dramatically, computations. All of those are in
the Ministry textbook, and the exam will use them.
Rewriting them into easier English was tried and reverted. It made the quiz easier to read and worse at its job: a student who only ever meets “order, earliest first” is ambushed by “chronological order” in the exam.
So the question stays in the exam’s English, and two optional fields carry the Arabic. They are not interchangeable, and the difference is when they show.
v: translates the hard words, and it renders above the options, before
the student answers. That timing is the whole point: a word a student cannot
read stops them answering a question they actually know, so help that waits
for the explanation has already failed. ar: carries the reasoning, and it
renders with the explanation, after the answer is locked in.
{ s:2, q:"According to Moore's Law, roughly how often does the number of "
+ "transistors on an integrated circuit double?",
o:[...], a:1,
v:[["roughly","تقريبًا"],
["integrated circuit","الدائرة المتكاملة — الشريحة"],
["double","يتضاعف — يبقى الضعف"]],
why:"Moore's Law is the empirical observation that ...",
ar:"كل سنتين تقريبًا. وخد بالك: empirical observation = ملاحظة من الواقع" }
Gloss only the heavy words. This is the part that is easy to get wrong in the generous direction, and a bloated list is worse than a short one: it pushes the options down the phone and trains the eye to skip the block, so it costs you the words that mattered.
The test: cover the word with your thumb. Can you still answer? Then cut it.
Do not ask “is this word hard” — that question keeps saying yes. Ask what the rest of the sentence already gives you. Three that were cut on exactly this, after they had been waved through as “moderately hard”:
| glossed | why it went |
|---|---|
staff |
“A company lets its staff work from home” — company and work from home carry it |
vehicle |
the question already says “a self-driving car” |
goods and services |
“buying and selling goods and services” — buying and selling carry it |
And point the gloss at the heavy half of a phrase, not the whole phrase.
computation, not scientific computation. Autonomous, not Autonomous
driving. The easy word riding along makes the list look longer than the help
it gives.
| gloss | don’t gloss |
|---|---|
| formal or Latinate English carrying no marks — chronological, Commercialization, purchasing, appropriate, dramatically, roughly, onward, computations, insights | everyday words — stage, habit, double, delay, mainly, FALSE, line by line |
| false friends, where the everyday meaning is the wrong one — notes and coins is not ملاحظات, works out is not يتمرن | Arabic loanwords they already say — transistor, electron, virtual, networking, hacked |
| technical phrases with no in-line explanation — integrated circuit, emerging technology | their own syllabus terms in English — Input/Processing/Output, Compiling, debug, machine language, Cybersecurity. They are meant to learn those in English; glossing them undoes the lesson |
| anything the sentence explains itself. quantum tunneling effect needs no gloss when its own option says “where electrons slip through barriers” |
The result is 10 of lecture 2’s 20 questions and 3 of lecture 1’s 10, most carrying a single term.
Three rules on v:, all enforced by check_lecture.py so they cannot be
broken quietly:
q or one of o. A gloss for wording the
student cannot see is dead weight at best, and a hint at worst. It also
means editing a question’s text and forgetting its gloss fails the build
rather than shipping.And never put reasoning in v:. It renders while the options are still live,
so “the right order is computer, then internet” there is an answer key printed
above the answers. That belongs in ar:.
Editing an option’s text means re-running the answer protection:
python scripts/protect_answers.py 2 --unlock # before you edit
...edit...
python scripts/protect_answers.py 2 # after
Intake requires a full name (two words or more), a class or group, and a date, which is pre-filled with today. The phone is optional — a student who would rather not give it is not blocked — but a phone that is typed still has to be 8–15 digits, because a half-typed number is a typo worth catching.
class is what identifies which group a result belongs to, so it is the
required field; it lands in a Class column in the sheet.
It used to be fire-and-forget: mode:"no-cors" meant the browser could not
read the reply, so the page said “sent” the moment the request left — true or
not. Thirty phones on one classroom network is exactly when that lies.
Apps Script does return CORS headers, so the reply is readable. Now:
localStorage and retried on the next
visit, without the screenshot so it stays smallnavigator.sendBeacon carries a slim
copy; a row with no picture beats no rowEvery attempt sends the same id, and the script ignores an id it has already
stored, so retrying cannot produce two rows for one student. If a later copy
carries the screenshot and the stored row does not, it fills it in.
The student only ever sees Saved when the script has said so.
Settings → Pages → Deploy from a branch → main / / (root). Folder names
map straight to URL paths, and every in-repo link is relative
(../quiz/, ../slides/), so the pages work opened from disk as well as on
Pages.
templates/ holds its own copy of the shared chrome (the CSS, the deck
navigation, the quiz engine). A change to how every lecture looks or behaves
belongs in both the template and the existing lectureN/ files — the
templates are only read when a new lecture is scaffolded.