A Discussion lesson with a Lab-type instrument console dropped into one of its segments. The reader argues about a contested subject, and the instruments are what they argue with — they operate the evidence rather than being told what it says.
The two parent types: a Discussion lesson (trilingual, evidence and argument, three open questions per segment) and a Lab lesson (bilingual, 5–8 machines the student drives). Read both before building a Hybrid — this page documents only what is different, and assumes the parents for everything else.
This file: HTML/template_hybrid.html · live at krueng.ai/template_hybrid.html.
Reference implementation: HTML/qi.html (2,733 lines, self-contained).
First of the type: HTML/qi.html.
Last updated 2026-08-02.
Reach for it when the subject is contested and instrumentable at the same time. A Discussion alone leaves the reader taking your word for the evidence. A Lab alone teaches a mechanism with nothing at stake. The Hybrid exists for the case where the argument is about what the measurements show, so the reader needs to handle the measurements before the argument means anything.
| Type | The student should… | Reference |
|---|---|---|
| Lab | operate the subject | neuro_lab.html |
| Discussion | argue about the subject | bitcoin.html |
| Hybrid | adjudicate — argue using instruments they drove themselves | qi.html |
Two competing claims, each with real evidence, that fail in different places. On
qi.html the pairing is qi and negative air ions: one has no unit and no instrument yet
produces measurable clinical results, the other has a unit, an instrument and a confirmed mechanism yet
fails on benefit. The instruments exist to make that inversion something the reader discovers rather
than something they are told.
If both claims fail in the same way, you have a Discussion. If neither is contested, you have a Lab.
A Discussion skeleton with one section replaced. The console goes in the middle, after both subjects have been introduced and before the synthesis — the reader needs the vocabulary to drive it and the argument needs the reader to have driven it.
<body class="lang-en">
.langbar — EN / 中文 / ไทย / sound, fixed
.navbtn + nav.toc + .toc-scrim
.topbtn
header.hero#intro
<main>
section#vocabulary — 01, always
section#<subject-a> — 02, the first claim
section#<subject-b> — 03, the second claim
section#lab — 04, THE CONSOLE — the only new part
section#measured — 05, what the evidence shows
section#inversion — 06, the synthesis, usually a table
section#<coda> — 07, the transferable habit
</main>
footer — sources, label key, what the instruments are NOT
<script>
Seven sections is the working size. Every one still carries three summaries, a Listen button and exactly three discussion questions — the console section included. Its questions should be about what the reader found on the benches, not about the prose.
The parents disagree about theme: Discussion is warm paper on light, Lab is a dark instrument panel. Do not pick one. Scope the Lab's variables to the console block and let the page stay light around it — the tonal shift is what tells the reader they have moved from reading to operating.
:root{ /* the Discussion page */
--ink:#FBFAF7; --raised:#FFFFFF; --bone:#17191D;
--ochre:#0B6553; /* accent used as TEXT — must clear 4.5:1 */
--orange:#12B48C; /* accent used as FILL — chart bars, pressed buttons */
}
.console{ /* the Lab console, scoped — nothing leaks out */
--c-bg:#0A1219; --c-panel:#101E29; --c-line:#23404F;
--c-ink:#E7F1F5; --c-dim:#9DB4C0;
--c-qi:#3FD6AE; --c-ion:#6BB6F7; /* one hue per subject, used everywhere
--c-amber:#F5C24A; --c-red:#FF7183; for that subject */
}
Retaining --ochre and --orange rather than inventing
--jade and --jade-bright means every rule copied from
bitcoin.html keeps working after a hue change. qi.html shifted the whole page
from orange to jade by editing two values. Rename them and you inherit nothing.
Give each subject one hue and never reuse it. On qi.html everything about
qi is --c-qi and everything about ions is --c-ion, in the tab bar, the markers, the
gauges and the effect-size dots. The reader learns the mapping in about four seconds and it then does the
work of a legend on every subsequent bench.
The Lab's lesson-tab lifecycle, renamed. Tab id X ⇄
<div class="bay" id="bay-X">, visibility is purely a class, and
every animated bench needs a matching stop call.
var BENCHES=[['ladder','⚖️ Claim ladder'],['room','🌬️ Ion room'],
['breath','🫁 Resonance breathing'],['trials','📊 Effect sizes'],
['needle','🪡 Needle grasp'],['chance','🎲 Chance vs skill']];
window.showBench=function(id){
BENCHES.forEach(function(b){
document.getElementById('bay-'+b[0]).classList.toggle('on', b[0]===id);
var t=document.getElementById('tab-'+b[0]);
t.classList.toggle('on', b[0]===id);
t.setAttribute('aria-selected', b[0]===id?'true':'false');
});
// stop on the way OUT, not merely start on the way in —
// a hidden rAF loop otherwise runs behind the next bench forever
if(id!=='room') roomStop();
if(id!=='breath') breathStop();
if(id!=='needle') needleStop();
if(id==='room') roomKick();
};
Six benches is the working size — enough to cover a real argument, few enough that the tab bar does not wrap twice on a phone. Use the Lab's three figure patterns (scrubbable scalar, live simulation, clickable diagram) and add one shape the Lab does not have:
| Bench | Pattern | Does the argumentative work of… |
|---|---|---|
| Claim ladder | clickable diagram | the whole page, in one picture — build this first |
| Live mechanism | live simulation | showing a real effect and its unadvertised cost |
| Response curve | scrubbable | letting the reader find an optimum rather than be told it |
| Effect sizes | selector → chart + prose | the new one — "real" versus "big" |
| Mechanism detail | scrubbable | one confirmed mechanism, at proper scale |
| Statistical intuition | live simulation | letting chance imitate a real effect in front of them |
Rungs of evidence from "can it be counted at all" to "is the mechanism confirmed," with every claim placed at the highest rung it actually reached, in its subject's colour. It is the only figure that shows the shape of the disagreement, and it is what the rest of the page keeps pointing back to. Design it before you write the prose; if you cannot place your claims on rungs, the page has no argument yet.
The bench the Discussion type is missing. Rows carry either a point estimate with a real published confidence interval, or several point estimates, or a plain verdict where no single number exists. Never invent an interval to make the chart look complete.
{id:'acu-sham', side:'qi', scale:'smd', nullv:0,
pts:[{l:'back & neck pain',v:0.23},{l:'osteoarthritis',v:0.16},{l:'chronic headache',v:0.15}],
st:'part', ttl:'…', d:'…', s:'Vickers et al., 29 RCTs, 17,922 patients'},
{id:'ion-anx', side:'ion', scale:'note', st:'not', // no pooled number exists
ttl:'…', d:'…', s:'…'},
scale:'note' renders a word — SUPPORTED / WEAK SIGNAL ONLY / NOT SUPPORTED — instead of a
bar. A row with no number is a finding, not a gap, and drawing a bar for it would be a lie about the
evidence.
The Discussion type labels claims verifiable / contested /
argument — that stays, in the prose. The console needs a second, different axis,
because "how much evidence is there" is not the same question as "who is asserting this".
| Console state | Colour | Means |
|---|---|---|
est | --c-qi / --c-ion, filled | established — replicated, or mechanically unambiguous |
part | --c-amber, hollow | one signal, with limits the source itself states |
not | --c-red, hollow | tested and not supported, or never demonstrated |
Filled means established; hollow means it stops here. The reader reads the fill before they read the label, so the fill has to carry the truth on its own.
The temptation is to soften the failures of the subject you find sympathetic and sharpen the ones you do not. The console makes that visible at a glance because both subjects are on one figure in one notation — which is exactly why the type works, and exactly why it is unforgiving of a thumb on the scale.
Three bugs from the qi.html build. All three passed code review and were caught only by
driving the page in a headless browser. Copy the fixes.
The Lab template's self-halt idiom is if(!box.offsetParent){ stop(); return; }, written
for a <div> host. Every console stage is an <svg>, where
offsetParent is undefined — so the guard is always true and every
animation loop kills itself on its first frame. The Animate buttons simply did nothing, silently, with
no console error.
function onScreen(id){ var e=document.getElementById(id);
return !!(e && e.getClientRects().length); } // works for HTML and SVG
SVG's default pointer-events: visiblePainted hit-tests only where paint actually landed.
A hollow marker is therefore clickable on its ~1px ring and nowhere else — and since hollow is how the
ladder shows weak or failed evidence, ten of sixteen markers were effectively dead.
.hit .mk{pointer-events:all}
Ladder markers sit centred exactly on their rung lines, so the lines intercepted the clicks meant for the markers.
.cgrid,.crule,.cx{pointer-events:none}
getBBox() on a
display:none subtree returns nothing — and reports a clean pass over five untested
figures. Click each tab, then measure.This type puts numbers on screen with citations attached, so it carries more risk than either parent. Two passes, and the second is not optional.
source add-research "…" --mode deep). Count the sources afterwards — the importer has
historically double-imported.qi.html build this caught
two claims already written into the page: that indoor air is ion-depleted (measurement says the
opposite, and it is the premise most ionizer marketing rests on), and a pooled depression effect that
turned out to be a within-arm before/after change rather than a placebo-controlled difference.Where verification changes the story, put the change on the page rather than quietly dropping the claim. "In mice, and the human follow-up was much weaker" is more useful to a student than either the bare result or its omission. The footer should also say plainly which benches are published findings and which are working models scaled to published measurements — a reader who cites your simulation has been misled by you, not by themselves.
Same pipeline as both parents, and the same rule: do not fork the builder. Config per page, one generic script.
python python/video_gen/build_page_explainer.py qi
reads python/video_gen/page_explainers/qi.py
writes qi_explainer.mp4 + .en.vtt + .zh.vtt
VOICE = "en-US-AriaNeural" # edge-tts, free
RATE = "-6%"
LINES = [
("English narration line.", "中文字幕。", ("card", "EYEBROW", "Head\nline")),
("A drawn one.", "画的。", ("figure", "ladder", "caption")),
]
FIGURES = {"ladder": draw_ladder, …} # PIL functions in the same config
| Visual kind | Use for |
|---|---|
("card", eyebrow, headline) | A typographic slide. Numbers, contrasts, statements. |
("photo", filename, caption) | A real photograph the page already credits. |
("spark", prompt, caption[, avoid]) | An SDXL still from the Spark. Optional 4th element appends to the negative prompt. |
("figure", key, caption) | A drawn schematic — dispatches to FIGURES[key]. |
The console's benches are already specified geometry. Redraw them with PIL in the console's own
palette and the viewer who arrives at the page recognises what they are looking at — the video becomes
a trailer for the instruments rather than unrelated decoration. Draw the figures at
1080 × 530, the box draw_photo() scales into, so nothing is resampled
twice and the mono type stays crisp.
It also means no Spark dependency: qi_explainer.mp4 builds with the box
switched off. And a page arguing that people accept invisible claims too readily should not illustrate
itself with generated pictures — qi.py declares that in its module docstring, which is
where the next person will look.
Embed it under the intro segment's heading, with both subtitle tracks. The 中文 toggle switches the zh
track on via syncSubs():
<figure class="vidfig">
<video controls preload="metadata" playsinline width="1280" height="720">
<source src="../video/qi_explainer.mp4" type="video/mp4">
<track kind="subtitles" src="../video/qi_explainer.en.vtt" srclang="en" label="English">
<track kind="subtitles" src="../video/qi_explainer.zh.vtt" srclang="zh" label="中文">
</video>
</figure>
Encode for mobile: -profile:v main -level 4.0 -movflags +faststart
-pix_fmt yuv420p. Default ffmpeg output plays on a PC and is silently refused by Android and by
the LINE in-app player.
Invalidate CloudFront after re-uploading, or the old cut keeps serving:
aws cloudfront create-invalidation --distribution-id E2F1VRCSRT1FEV --paths "/video/<name>.mp4"
--profile claude-deploy.
Rebuild when the page changes. The video narrates the page's claims in the page's order; restructure the page and it goes stale silently. State the real runtime in the caption rather than guessing it.
Every krueng.ai video ends with a warm sign-off, placed after the closing hook —
bitcoin.py, neuro_lab.py and qi.py all do. The packaging linter
warns that an exit signal costs retention; that warning is non-blocking and we accept it.
Copy these from bitcoin.html verbatim. They are documented in the
Discussion template and none of them need Hybrid-specific changes:
speakSummary() reading whichever languages are
on screen rather than always English.bestVoice() — rank voices by name; getVoices()[0] lands on the worst one..console to the skip list, or the walker marks
up JS-rendered panels that re-render on interaction and lose their marks.localStorage keys.
Namespace the keys per page (qi.lang, qi.sound) or two
lessons fight over them.Headless Playwright. The Discussion checks all still apply; these are the additions.
| Check | Asserts |
|---|---|
| Benches render | each bay visible when its tab is active; its stage has > N nodes |
| Controls do something | gauges change after driving each control — a correct no-op is indistinguishable from a dead button |
| Animations stop | rAF tick count is 0 shortly after leaving an animated bench |
| Markers clickable | every marker, by mouse and by Enter — not a sample |
| viewBox overflow | per bench, while visible, at several slider positions |
| Text collisions | pairwise bbox overlap between texts in the same SVG |
| Contrast | light page and dark console, in every language combination |
| Layout | no horizontal scroll at 1280 / 900 / 390 |
The qi.html suite passed clean while one caption sat outside its viewBox and two others
overlapped a data trace. Tests confirm what you thought to ask. Only looking catches the rest.
HTML/qi.html. Rename the localStorage keys.showBench()'s stop list.page_explainers/<name>.py — card + figure, figures at 1080 × 530, close with
a goodbye. Build the video, state the true runtime, upload mp4 + both VTTs to S3.HTML/index.html. Commit on a feature branch and push — never to main,
never HTML to S3.Both parents have one. This type's is: the instruments must be able to embarrass you. If every bench flatters the conclusion you had before you built them, you have made an illustration, not an instrument, and the reader will be able to tell.