krueng.ai · page type reference

The Discussion lesson

A long-form, trilingual, read-aloud explainer page built around evidence and argument, with a vocabulary drill at the front and three discussion questions at the end of every segment. Designed for a classroom where the reading is done alone and the talking is done together.

Sibling page types: a Lab lesson — bilingual, built around 5–8 interactive machines the student drives themselves — and a Hybrid lesson, this page's structure with a Lab-type instrument console dropped into one segment. Reach for a Discussion when the student should argue about the subject, a Lab when they should operate it, and a Hybrid when they should adjudicate — argue using instruments they drove themselves.

This file: HTML/template_discussion.html · live at krueng.ai/template_discussion.html.
Reference implementation: HTML/bitcoin.html (2,966 lines, self-contained).
First of the type: HTML/prehistory.html.
Last updated 2026-08-02.

Contents
  1. What a Discussion lesson is
  2. Page skeleton
  3. The segment pattern
  4. Language buttons and the sound button
  5. The summary system
  6. The TTS system
  7. The vocabulary system
  8. Listen & Repeat and the speaking drill
  9. Glossary tooltips
  10. Navigation: contents drawer and back to top
  11. The explainer video
  12. Interactive visualisations
  13. Claim tiers
  14. Images: Spark and public media
  15. Accessibility rules
  16. The test suite
  17. Deploying
  18. Checklist for a new lesson

1 · What a Discussion lesson is

A single self-contained HTML file — no build step, no framework, no external JavaScript — that teaches a contested subject to an intermediate-or-better English learner, in a way that produces conversation rather than recall.

Four commitments define the type:

Why "Discussion" and not "Lesson"

The other page types on krueng.ai (see edu/TEFL_tech_syllabus.MD) drive toward a right answer. This one drives toward a disagreement the class can have. The subject has to support that: it needs real evidence and real dispute. Bitcoin and prehistory both qualify. "How to use the past simple" does not.

2 · Page skeleton

<head>
  <title> <meta description>          — SEO, see PACKAGING_FOR_SEARCH.md
  Google Fonts: Archivo + Spectral + IBM Plex Mono
  <style>                             — the whole stylesheet, inline
</head>
<body>
  .langbar                            — EN / 中文 / ไทย / sound, fixed
  button.navbtn  + nav.toc + .toc-scrim   — contents drawer
  button.topbtn                       — back to top
  header.hero#intro                   — eyebrow, h1, standfirst, hero figure
  <main>
    section#vocabulary                — ALWAYS section 01
    section#…                         — 02 … N, one per segment
  </main>
  footer                              — sources, numbers-that-move, image credits
  <script>                            — one IIFE per widget, all inline
</body>

CSS variables

Every colour routes through these. Restyling the page means changing the values, not the rules — which is how bitcoin.html was flipped from a dark theme to a light one in a single patch.

VariableRoleNote
--inkpage backgroundname is semantic, not literal — do not assume dark
--groundfooter, drawerone step from the page
--raisedcards, panels
--bonebody text
--bone-dimcaptions, labelsmust still clear 4.5:1
--ochreaccent used as textlinks, small caps, eyebrows
--orangeaccent used as fillchart bars, pressed buttons
--steppe --moss --clay --violet --goldcategoricalchart series, claim tiers, asset hues
--rulehairlines
--measurereading width34rem; .wrap uses it, .wide is 56rem
Two accents, not one

A bright accent that looks right as a chart bar will usually fail contrast as text. Keep the readable one (--ochre) and the vivid one (--orange) separate from the start; retrofitting the split is fiddly.

Width classes

.wrap is the reading column. .wide is for figures and widgets. A figure placed inside a .wrap inherits the narrow measure and will be clamped — close the wrap, place the figure as a sibling, reopen the wrap:

  </div>

  <figure class="plate wide">…</figure>

  <div class="wrap">

3 · The segment pattern

Every section after Vocabulary uses this shape. Deviating from it breaks the summary, the Listen button and the test suite at once.

<section id="slug">
  <div class="wrap sec-head">
    <div class="eyebrow">NN — Title · date range</div>
    <h2>A sentence with a point of view</h2>
    <button class="hear seclisten" type="button" onclick="speakSummary(this)"
            aria-label="Listen to a summary of this section">
      <span class="htag">Summary</span><span class="hlbl">🔊 Listen</span></button>
  </div>

  <div class="wrap sum sum-en"><span class="tag">Summary</span><p>…</p></div>
  <div class="wrap sum sum-zh"><span class="tag">摘要</span><p>…</p></div>
  <div class="wrap sum sum-th"><span class="tag">สรุป</span><p>…</p></div>

  <div class="wrap">  …prose, .claim boxes, .pull quotes…  </div>
  <figure class="viz">  …an interactive visualisation…  </figure>
  <figure class="plate wide">  …credited photographs…  </figure>

  <div class="wrap disc">
    <span class="dtag">Talk about it</span>
    <ol><li>…</li><li>…</li><li>…</li></ol>
  </div>
</section>
Exactly three questions

Not two, not four. The test suite asserts it: disc_q_counts must be all 3s. Three fits a fifteen-minute pair discussion, and the constraint forces you to pick the best three rather than dumping everything you thought of.

Write them as things a person could disagree about, anchored to the reader's own life where possible — "Where do you store value?" outperforms "What is a store of value?"

Renumbering

Inserting a segment shifts every number below it. Four things must move together, and a fifth is easy to miss:

  1. <div class="eyebrow">NN —
  2. <span class="toc-no">NN</span> in the drawer
  3. <!-- ====== NN NAME ====== --> section comments
  4. every in-text cross-reference: "section NN"
  5. the JS block comments /* ===== NN · widget ===== */ — cosmetic, and they went stale twice during the bitcoin build

Always replace in descending order (09→10, then 08→09 …) or the replacements collide.

4 · Language buttons and the sound button

<div class="langbar" role="group" aria-label="Section summaries and sound">
  <button class="lbtn" id="enBtn" onclick="toggleLang('en')" aria-pressed="false">EN</button>
  <button class="lbtn" id="zhBtn" onclick="toggleLang('zh')" aria-pressed="false">中文</button>
  <button class="lbtn" id="thBtn" onclick="toggleLang('th')" aria-pressed="false">ไทย</button>
  <button class="lbtn on" id="sndBtn" onclick="toggleSound()" aria-pressed="true">🔊 Sound</button>
</div>

The three language buttons are independent toggles, not a radio group. English + Thai together is the combination a Thai learner actually wants, and forcing a single choice removes it. Each adds or removes body.lang-XX; CSS does the rest:

.sum{display:none}
body.lang-en .sum-en{display:block}
body.lang-zh .sum-zh{display:block}
body.lang-th .sum-th{display:block}

State persists in localStorage under <page>.lang and <page>.sound. Namespace the key per page or two lessons will fight over it.

window.soundOn is a single global gating every audio path — the summary reader, the vocabulary voice, the chart read-aloud, and the WebAudio chimes in the drill. Muting must be silent, not merely quieter.

5 · The summary system

Each segment carries three summary blocks. They are hidden by default: a reader who wants the full text gets it, and a reader who wants the gist turns on a language. They are also the script the Listen button reads, which means a summary must be worth hearing, not just worth seeing — write it as speech.

Keep each to two or three sentences. They double as the per-section abstract used when drafting the explainer video narration.

Never mark up inside .sum

The glossary walker explicitly skips .sum blocks. They are display:none until toggled, and the zh/th ones are not English — marking them would spend each term on an invisible copy and leave the real prose bare. If you add another auto-annotation pass, exclude .sum too.

6 · The TTS system

Browser speech synthesis, no API, no cost, no network. Three parts.

bestVoice(lang)

Voice quality is whatever the visitor's OS ships, and getVoices()[0] reliably lands on the worst one — a stock Windows box offers only the 2013-era David/Mark/Zira. So rank by name instead:

window.bestVoice=function(pref){
  var GOOD = pref==='zh' ? /\b(xiaoxiao|xiaoyi|yunxi|…)\b/
           : pref==='th' ? /\b(premwadee|niwat|achara|kanya)\b/
           : /\b(aria|jenny|guy|ava|andrew|emma|…)\b/;
  // +100 natural|neural, +60 named, +50 google, +30 enhanced,
  // +15 remote, −80 desktop|espeak|compact
};

Computed on demand, never cached: getVoices() is empty until the engine finishes loading, and every caller runs on a user gesture anyway.

speakSummary(btn)

Reads whichever summaries are currently on screen, each with a voice matched to its language, in order. With no toggle on it defaults to English.

var want=['en','zh','th'].filter(l => document.body.classList.contains('lang-'+l));
if(!want.length) want=['en'];
The bug this replaced

Both pages originally read .sum-en p with an English voice regardless of the toggle — so switching to 中文 showed Chinese and spoke English. It shipped on two pages before anyone noticed. If you fork this file, make sure you fork the fixed version.

Two details that are easy to get wrong:

Per-widget read-aloud

Charts with clickable detail (the timeline) carry their own opt-in toggle — tlToggleSound() — so a reader can have spoken detail on one chart without the whole page talking.

7 · The vocabulary system

Section 01, always. Cards are generated from one array; nothing is hand-written in the HTML.

{id:'stock-to-flow',                        // kebab-case, stable, used by tooltips
 w:'stock-to-flow',                         // display form
 alt:['stock to flow'],                     // other surface forms in the prose
 ipa:'STOK too FLOH',                       // respelling, NOT real IPA — say-able
 zh:'存量流量比 — …', th:'สต็อกต่อโฟลว์ — …',   // gloss per language
 df:'The existing stockpile divided by …',  // one-line definition, on the card
 deep:'The central measurement in …',       // "tell me more", also read aloud
 eg:'Gold’s stock-to-flow is roughly sixty years.'}   // a sentence to say
ControlDoes
🔊 say itvSpeak(id) — reads the word alone at rate 0.82
📖 tell me morevDeep(id,btn) — reveals deep + eg, and reads the explanation
🔊 beside the examplevSpeakText(eg) — reads the sentence
Sizing

26 terms was comfortable; 54 (bitcoin, after adding every term the prose actually used) is at the top end and makes the section long. If you pass ~40, consider splitting the drill into themed sets rather than random ones.

Audit rather than guess which words to include: strip the tags out of <main>, lowercase it, and count occurrences of your candidate list. Terms the prose uses but never defines are the ones that matter.

8 · Listen & Repeat and the speaking drill

Listen & Repeat — vToggleCycle()

Walks every card: highlights it, speaks the word, waits 1.7s, flips to "YOUR TURN" with a chime, waits 2.4s, moves on. Scrolls each card into view as it goes.

Driven by await sleep(ms) rather than by utterance.onend, because onend does not fire reliably across engines. The cycling flag is re-checked after every await so Stop takes effect within one step.

Speaking drill — vStartDrill()

Shuffles the vocabulary into sets of five, opens the microphone, and grades what it heard.

recog.lang='en-US';
recog.continuous=true;      // false auto-stops after ~2s of silence
recog.interimResults=true;
recog.onend = …             // restart unless recStopping — continuous still ends on long silence
Three things that will break it

micStart() must stay synchronous. An await before recog.start() breaks the user-gesture chain and the browser intermittently refuses — which reads as a dead button.

The restart loop needs a hard stop flag. recStopping blocks the onend auto-restart, or a real Stop loses the race and the microphone never stops.

Speech recognition needs https and Chrome or Edge. Safari and Firefox will not work. Pre-flight and say so plainly — the page maps every error code to an action the reader can take, rather than printing the raw API string.

Grading is deliberately loose: exact substring, else a 70%-positional-character match. Recognition rarely returns technical terms letter-perfect, and failing someone for a plausible transcription teaches the wrong lesson.

9 · Glossary tooltips

Every vocabulary term is marked once per section in the prose; hover, focus or tap shows the definition in whichever languages are on.

Contents drawer.navbtn opens nav.toc over a scrim; closes on link click, on scrim click, and on Escape. Numbers mirror the section eyebrows.

Back to top.topbtn, hidden until 0.9 of a viewport has been scrolled, then fades in. Two things worth copying:

window.scrollTo({top:0, behavior: reduce ? 'auto' : 'smooth'});
var h=document.querySelector('h1');
if(h){ h.setAttribute('tabindex','-1'); h.focus({preventScroll:true}); }  // or a keyboard user lands back at the bottom

On screens under 34rem the langbar owns the bottom-right corner, so the button stacks above it (bottom:3.1rem). Check for overlap when you change either.

11 · The explainer video

Sits directly under the intro segment's heading. Built by a generic script — do not fork it per page:

python python/video_gen/build_page_explainer.py <name>
  reads  python/video_gen/page_explainers/<name>.py
  writes <name>_explainer.mp4 + .en.vtt + .zh.vtt

The config is a list of cues, one per subtitle:

VOICE = "en-US-AriaNeural"     # edge-tts, free
RATE  = "-6%"
LINES = [
  ("English narration line.", "中文字幕。", ("card", "EYEBROW", "Head\nline")),
  ("Another line.",           "另一行。",   ("photo", "bitcoin/rai_stones_yap.jpg", "caption")),
  ("A drawn one.",            "画的。",     ("spark", "an SDXL prompt", "caption")),
]

One TTS render per line, so every subtitle cue is timed against real audio rather than estimated. Visual kinds: card (typographic), photo (from python/video_gen/imgs/), spark (SDXL still, cached by line index), figure (a drawing function in the config).

Embed with both subtitle tracks; the 中文 toggle switches the zh track on via syncSubs():

<video controls preload="metadata" playsinline width="1280" height="720">
  <source src="../video/<name>_explainer.mp4" type="video/mp4">
  <track kind="subtitles" src="../video/<name>_explainer.en.vtt" srclang="en" label="English">
  <track kind="subtitles" src="../video/<name>_explainer.zh.vtt" srclang="zh" label="中文">
</video>
Rebuild when the page changes

The video narrates the page's structure and claims. Restructure the page and the video silently goes out of date — it is the easiest thing on the whole page to leave stale. State the real runtime in the caption; do not guess it.

12 · Interactive visualisations

Hand-built inline SVG, no chart library. One IIFE per widget, each guarded with if(!host) return; so a widget missing from the markup cannot break the rest of the page.

Shared helpers

function el(tag, attrs)              // createElementNS + setAttribute
function txt(x, y, s, cls, anchor)   // text node
function esc(x)                      // & < > " for innerHTML
function barChart({rows, axis, max, y0, step, ticks, fmt, caption, data})

Patterns worth reusing

PatternWhereGood for
Horizontal bar chartbarChart() ×3ranked quantities, uncertainty bands
Clickable timeline → detail pane§05 precursorsa sequence where each item needs a paragraph
Selector → bars + prose panel§06 comparator, §05 scorecardcomparing things across shared properties
Two static panels side by side§02 schoolsexactly two things — a toggle is worse than showing both
Live computation§04 hash chain, node costletting the reader break it themselves
Multi-series line + event markers§02 debtchange over time with causes annotated

SVG label rules learnt the hard way

Live computation

The hash chain computes real SHA-256 in a ~45-line synchronous implementation rather than crypto.subtle, which is async and refuses to run outside a secure context — the demo would be dead on a local file preview. It self-checks against the published "abc" vector on load and says so in the page if it ever fails.

Interactive controls must show they did something

The chain's mine and reset buttons were reported as broken. They worked perfectly — mining an already-valid block re-finds the identical nonce, and resetting an untouched chain rebuilds identical state, so nothing on screen changed. A correct no-op is indistinguishable from a dead button. Every control now reports what happened, including when the answer is "nothing needed doing".

13 · Claim tiers

The device that makes the page trustworthy. Every substantial assertion goes in a labelled box:

<div class="claim" data-tier="verifiable">
  <div class="claim-head">
    <span class="tier">Verifiable</span>
    <span class="cat">Source, date, or citation</span></div>
  <p>…</p>
</div>
TierColourMeans
verifiable--rustyou can check it yourself, and the box says how
contested--steppeserious people disagree; the box gives both sides
argument--violetsomebody's position, presented as a position

Teach the reader to use the labels, then say so in the coda. It is the one habit that survives the page.

14 · Images: Spark and public media

Two classes, kept strictly apart, and the footer explains the difference to the reader.

Credited photographs

Wikimedia Commons only, and only public domain / CC0 / CC BY / CC BY-SA. Author and licence in the caption and in the footer. Fetch with a script that records the credit next to the URL, so the attribution and the download cannot drift apart — python/img_gen/fetch_bitcoin_commons.py.

Generated illustrations

Rendered locally on the DGX Spark via python/video_gen/spark_media.py (t2i() SDXL still, i2v() motion, clip() both). Every one is labelled "Generated illustration, not a photograph" in its own caption. Never use one as evidence.

Rules

15 · Accessibility rules

16 · The test suite

Headless Playwright, run from the repo root. These caught real bugs that reading the code did not.

CheckAsserts
Structuresection count, .disc ol all 3, listen buttons = sections, TOC links = sections
Widgetseach renders; tamper/mine/reset cascade correctly; selectors switch panels
Glossarymarks > 0, none inside .sum, a or #vocabulary; prose text intact
Speechstub the engine, assert the language of each utterance per toggle combination
Contrastevery visible text node against its composited background
Layoutno h-scroll at 1280/390; no SVG text past the viewBox; no broken images
Test the harness too

Three separate false alarms during the bitcoin build came from the test, not the page: transparent backgrounds read as black; utterance.voice rejecting a plain stand-in object and throwing inside the handler; and loading="lazy" images reported as broken because they had not scrolled into view. When a result looks catastrophic, suspect the harness first.

And what tests cannot see

gold_first_bar_pct: "100%" passed while every comparator bar rendered empty — .fill was an inline <span>, where width does not apply, and reading back .style.width returns what you set, not what was used. Screenshot the widgets and look at them.

17 · Deploying

  1. HTML goes through the GitHub repo on a feature branch — never aws s3 cp.
  2. Media goes to S3: s3://krueng.ai/imgs/<page>/ and s3://krueng.ai/video/, with correct --content-type (text/vtt; charset=utf-8 for subtitles).
  3. Pages are flat: HTML/foo.htmlkrueng.ai/foo.html. Never HTML/foo/index.html — S3 and CloudFront do not resolve directory indexes.
  4. Reference media as ../imgs/… and ../video/….
  5. Add a card to HTML/index.html.
  6. Commit and push in one action. No programmatic PRs.

18 · Checklist for a new lesson

  1. Pick a subject with real evidence and real dispute.
  2. Copy HTML/bitcoin.html. Rename the localStorage keys.
  3. Write the segments first — prose, claim boxes, three questions each.
  4. Audit the prose for undefined technical terms; build the vocabulary from what you find.
  5. Design one visualisation per segment. Prefer something the reader can break.
  6. Write the EN summary for each segment, then translate to zh and th.
  7. Source images: Commons for anything real, Spark for anything that cannot be photographed.
  8. Write page_explainers/<name>.py; build the video; state the true runtime.
  9. Run the whole test suite. Screenshot every widget and actually look.
  10. Upload media to S3, commit HTML on a branch, push, add the index card.
The one rule

If a claim cannot be labelled verifiable, contested or argument, it is not written clearly enough yet.