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.
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:
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.
<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>
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.
| Variable | Role | Note |
|---|---|---|
--ink | page background | name is semantic, not literal — do not assume dark |
--ground | footer, drawer | one step from the page |
--raised | cards, panels | |
--bone | body text | |
--bone-dim | captions, labels | must still clear 4.5:1 |
--ochre | accent used as text | links, small caps, eyebrows |
--orange | accent used as fill | chart bars, pressed buttons |
--steppe --moss --clay --violet --gold | categorical | chart series, claim tiers, asset hues |
--rule | hairlines | |
--measure | reading width | 34rem; .wrap uses it, .wide is 56rem |
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.
.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">
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>
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?"
Inserting a segment shifts every number below it. Four things must move together, and a fifth is easy to miss:
<div class="eyebrow">NN — <span class="toc-no">NN</span> in the drawer<!-- ====== NN NAME ====== --> section comments"section NN"/* ===== NN · widget ===== */ — cosmetic, and
they went stale twice during the bitcoin buildAlways replace in descending order (09→10, then 08→09 …) or the replacements collide.
<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.
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.
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.
Browser speech synthesis, no API, no cost, no network. Three parts.
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.
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'];
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:
[^.!?。!?]+ — some engines truncate one long utterance, and
without the full-width stops an entire Chinese summary is a single chunk.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.
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
| Control | Does |
|---|---|
🔊 say it | vSpeak(id) — reads the word alone at rate 0.82 |
📖 tell me more | vDeep(id,btn) — reveals deep + eg, and reads the explanation |
🔊 beside the example | vSpeakText(eg) — reads the sentence |
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.
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.
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
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.
Every vocabulary term is marked once per section in the prose; hover, focus or tap shows the definition in whichever languages are on.
\b boundaries stop "block" matching inside "blockchain".a, button, code, textarea, .gl, .vcard, .sum and the whole
vocabulary section.window.__glRefresh() on toggle.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.
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>
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.
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.
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})
| Pattern | Where | Good for |
|---|---|---|
| Horizontal bar chart | barChart() ×3 | ranked quantities, uncertainty bands |
| Clickable timeline → detail pane | §05 precursors | a sequence where each item needs a paragraph |
| Selector → bars + prose panel | §06 comparator, §05 scorecard | comparing things across shared properties |
| Two static panels side by side | §02 schools | exactly two things — a toggle is worse than showing both |
| Live computation | §04 hash chain, node cost | letting the reader break it themselves |
| Multi-series line + event markers | §02 debt | change over time with causes annotated |
chars × 3.6 at 5.8px mono) and flip the label inside
the bar when it would overflow. getBBox() is unavailable before the node is in the document.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.
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".
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>
| Tier | Colour | Means |
|---|---|---|
verifiable | --rust | you can check it yourself, and the box says how |
contested | --steppe | serious people disagree; the box gives both sides |
argument | --violet | somebody'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.
Two classes, kept strictly apart, and the footer explains the difference to the reader.
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.
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.
upload.wikimedia.org paths break silently.File:Eric Hughes (14048942790).jpg is a 1919
portrait of an unrelated Australian, not the cypherpunk..duo with a shared height and
object-fit:contain — mixing a portrait museum shot with a wide landscape at natural size
leaves a hole beside the shorter one.imgs/ and video/ are gitignored..viz-scroll, never the body.tabindex,
role="button" and Enter/Space handling.aria-pressed on every toggle; aria-live="polite" on panels that swap content.prefers-reduced-motion for reveals and for smooth scrolling..notdef boxes.Headless Playwright, run from the repo root. These caught real bugs that reading the code did not.
| Check | Asserts |
|---|---|
| Structure | section count, .disc ol all 3, listen buttons = sections, TOC links = sections |
| Widgets | each renders; tamper/mine/reset cascade correctly; selectors switch panels |
| Glossary | marks > 0, none inside .sum, a or #vocabulary; prose text intact |
| Speech | stub the engine, assert the language of each utterance per toggle combination |
| Contrast | every visible text node against its composited background |
| Layout | no h-scroll at 1280/390; no SVG text past the viewBox; no broken images |
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.
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.
aws s3 cp.s3://krueng.ai/imgs/<page>/ and s3://krueng.ai/video/,
with correct --content-type (text/vtt; charset=utf-8 for subtitles).HTML/foo.html → krueng.ai/foo.html.
Never HTML/foo/index.html — S3 and CloudFront do not resolve directory indexes.../imgs/… and ../video/….HTML/index.html.HTML/bitcoin.html. Rename the localStorage keys.page_explainers/<name>.py; build the video; state the true runtime.If a claim cannot be labelled verifiable, contested or argument, it is not written clearly enough yet.