curriculum

PGteach — Curriculum

Interactive lecture slides and self-check quizzes, served straight from GitHub Pages. One folder per lecture, two pages per lecture.

Live URLs

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

Structure

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.

Adding a lecture

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:

  1. Slides — add or remove <section class="slide"> blocks in lectureN/slides/index.html. Keep the QR slide last.
  2. Questions — edit SECTIONS and QUESTIONS at the top of the <script> in lectureN/quiz/index.html.
  3. Handout — one <section class="sheet"> per printed A4 page in lectureN/handout/index.html.
  4. 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.

  5. 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.

  6. Open all three in a browser, then commit and push to main.

Answer positions do not matter

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.

Every lecture is the same shape, not the same colour

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.

Photos open full size when you click them

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.

A slide that is too tall shrinks itself

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.

Nothing needs counting by hand

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.

Writing slides

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.

Printing the handout

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).

Quiz results

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.

Do not simplify the English. Gloss it.

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:

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

Quiz results

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.

The submission is confirmed, not assumed

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:

  1. it POSTs with the screenshot and reads the reply
  2. on failure it retries — at 0s, 5s and 15s — and the student is told “the connection is slow, trying again”, never “sent”
  3. anything still unsent is kept in localStorage and retried on the next visit, without the screenshot so it stays small
  4. if the page is closing mid-flight, navigator.sendBeacon carries a slim copy; a row with no picture beats no row

Every 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.

GitHub Pages

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.

Keeping the templates current

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.