reference implementation: neuro_lab.html

The Lab lesson

A page type for krueng.ai. A Lab teaches one substantial subject by making the student read it, hear it, say it, and operate it โ€” vocabulary first, then a video overview, then a set of interactive machines they drive themselves. Everything is bilingual English + ไธญๆ–‡, everything can be spoken aloud by the page, and the student's own speech is scored.

Sibling page types: a Discussion lesson โ€” long-form, trilingual, built around evidence and argument, with three open questions closing every segment โ€” and a Hybrid lesson, a Discussion with a console of this page's machines dropped into one segment. Reach for a Lab when the student should operate the subject, a Discussion when they should argue about it, and a Hybrid when they should adjudicate โ€” argue using instruments they drove themselves.

The reference implementation is HTML/neuro_lab.html. It is a single self-contained file โ€” no build step, no dependencies, no external CSS or JS. Copy it, gut the content, keep the systems. This page documents what those systems are and why each one is shaped the way it is.

Why single-file. These pages are deployed as raw objects to S3 behind CloudFront. There is no bundler and no module server. A page that depends on a second file is a page that can half-deploy, and a page with an external CDN dependency breaks the day that CDN does. Everything inline means the file either works or is obviously missing.

When to build a Lab, and when not to

Build a LabBuild something else
A subject with real internal mechanism โ€” something that moves and can be driven (neurons firing, genes copying, forces balancing). A vocabulary set or a grammar point. Use the lesson-builder skill.
Enough substance for 5โ€“8 distinct sub-topics, each worth its own interactive figure. A narrative or a story. See star_on_the_mountain.html.
Where being wrong on the internet is common and worth correcting explicitly. A game with win conditions and state. See rpg.html.

Contents

01 ยท Page skeleton

The order is the pedagogy. Do not rearrange it casually.

<header>              chrome: title, ไธญๆ–‡, sound, voice picker, mic test
<div class="wrap">
  .rail                jump links to the three top-level sections
  #micBanner           the single notification surface for the whole page
  .panel               intro โ€” what this is and how to work through it
  .panel #sec-words    1 ยท VOCABULARY   (+ summary + Listen)
  .panel #drillPanel   the speaking drill, revealed on demand
  .panel #sec-video    2 ยท OVERVIEW VIDEO (+ summary + Listen)
  .panel #sec-lab      3 ยท THE LAB โ€” tab bar + N .lesson blocks
  .panel               Points to Remember
</div>
<button id="toTop">
<script>              everything, in one block, at the end
Why words first. Every lesson below leans on the vocabulary constantly. A student who meets "threshold" for the first time inside a figure is decoding the word instead of watching the figure. The video comes second so the lessons have somewhere to land, and the machines come last because they are the part worth the most time.

The script is a single block at the end of <body>, so there is no DOMContentLoaded wrapper โ€” the DOM already exists. Boot is bare top-level calls:

renderVocab();
renderLabTabs();
showLesson("neuron");     // whichever tab is first
populateVoicePicker();
preflight();              // announces missing mic / insecure context

02 ยท Header chrome

<header>
  <div class="hbar">
    <div class="logo">๐Ÿง  NEURO LAB</div>
    <span class="spacer"></span>
    <button class="tbtn" id="cnBtn"  onclick="toggleCN()">ไธญๆ–‡</button>
    <button class="tbtn on" id="sndBtn" onclick="toggleSound()">๐Ÿ”Š Sound</button>
    <select class="tbtn" id="voiceSel" title="English voice"
            onchange="onVoicePicked(this.value)"></select>
    <button class="tbtn" onclick="runMicCheck(true)">๐ŸŽค Mic test</button>
  </div>
</header>

Sticky, z-index:60, blurred backdrop. The four controls are always reachable โ€” that is the whole reason it is sticky, because a student two thirds down the page still needs to turn on ไธญๆ–‡.

The mic test button earns its place. Browser speech recognition fails for half a dozen unrelated reasons and every one of them looks identical to the student โ€” nothing happens. A dedicated probe that says which one it is turns a dead page into a fixable one.

03 ยท Language system

English is the page. Chinese is a support layer that appears in place, inline, next to the thing it translates.

<p>Voltage is a difference between two points.
   <span class="cn">็”ตๅŽ‹ๆ˜ฏไธค็‚นไน‹้—ด็š„ๅทฎๅ€ผใ€‚</span></p>
.cn{color:var(--muted);font-size:.85em}
body:not(.show-cn) .cn{display:none}
let cnOn=false;
function toggleCN(){
  cnOn=!cnOn;
  document.body.classList.toggle("show-cn",cnOn);
  document.getElementById("cnBtn").classList.toggle("on",cnOn);
}
Why one CSS switch and not a translation table. An earlier page (gene_lab.html) keeps Thai in a 125 KB JS lookup object and rewrites .cn nodes through a MutationObserver. That buys a third language and translation of dynamically-rendered text โ€” at the cost that every render path must be idempotent and every caption must avoid rewriting itself per frame. A Lab with two languages does not need it. Write the Chinese inline and let CSS hide it.

Consequences worth knowing:

04 ยท Sound and the voice picker

One soundOn flag gates every audio path, so muting is silent rather than merely quieter.

let soundOn=true, actx=null;
function toggleSound(){
  soundOn=!soundOn;
  document.getElementById("sndBtn").classList.toggle("on",soundOn);
  document.getElementById("sndBtn").textContent = soundOn ? "๐Ÿ”Š Sound" : "๐Ÿ”‡ Muted";
  // muting must also cancel speech in flight AND reset any active Listen
  // button, or it sits there claiming to be playing
  if(!soundOn){ window.speechSynthesis.cancel(); clearHear(); }
}

Cues

A lazily-created AudioContext โ€” lazy because browsers refuse to create one outside a user gesture.

function tone(f,ms,type){
  if(!soundOn) return;
  try{
    actx = actx || new (window.AudioContext||window.webkitAudioContext)();
    const o=actx.createOscillator(), g=actx.createGain();
    o.type=type||"sine"; o.frequency.value=f;
    g.gain.setValueAtTime(.0001,actx.currentTime);
    g.gain.exponentialRampToValueAtTime(.18,actx.currentTime+.01);
    g.gain.exponentialRampToValueAtTime(.0001,actx.currentTime+ms/1000);
    o.connect(g); g.connect(actx.destination);
    o.start(); o.stop(actx.currentTime+ms/1000);
  }catch(e){}
}
const ding=()=>{tone(880,120);setTimeout(()=>tone(1320,140),110);};
const buzz=()=>tone(160,220,"square");

Voice picker

Do not use getVoices()[0]. On a stock Windows box that is Microsoft David or Zira โ€” 2013-era concatenative voices that mispronounce half the technical vocabulary. Students hear that as "the site says it wrong." Rank by name instead.
function scoreVoice(v){
  const n=(v.name||"").toLowerCase();
  let s=0;
  if(/natural|neural|online/.test(n)) s+=6;   // the modern families
  if(/google/.test(n))                s+=4;
  if(/aria|jenny|guy|zira|libby|sonia/.test(n)) s+=3;
  if(/en-us/i.test(v.lang)) s+=2;
  if(/en-gb/i.test(v.lang)) s+=1;
  if(v.localService)        s+=1;
  return s;
}
window.speechSynthesis.onvoiceschanged = populateVoicePicker;

getVoices() is empty until the engine finishes loading, hence the onvoiceschanged hook. The picker is exposed to the student because no ranking beats letting them hear it and choose.

05 ยท TTS โ€” speak()

function speak(text, rate, lang){
  if(!soundOn || !window.speechSynthesis) return;
  window.speechSynthesis.cancel();
  // split on sentence punctuation: some engines truncate one long utterance,
  // and short ones let a student stop halfway through
  String(text).split(/(?<=[.!?])\s+/).filter(Boolean).forEach(part=>{
    const u=new SpeechSynthesisUtterance(part);
    u.rate=rate||0.92; u.lang=lang||"en-US";
    if(chosenVoice && !lang) u.voice=chosenVoice;
    window.speechSynthesis.speak(u);
  });
}
ContextRate
A single vocabulary word0.85
An example sentence0.86
A paragraph of explanation0.92
A section summary0.9

Slower than default throughout, because the audience is learning English as a second language and the words are technical.

06 ยท Section summaries and the Listen button

Every major section carries a two-or-three sentence summary in both languages and a button that reads the English aloud.

<h3>โšก The spike</h3>
<div class="sechead">
  <button class="hear" 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="sum sum-en"><span class="tag">Summary</span><p>At about minus fifty-five
  millivolts the sodium gates snap open and the cell fires. โ€ฆ</p></div>
<div class="sum sum-zh"><span class="tag">ๆ‘˜่ฆ</span><p>ๅœจ็บฆ โˆ’55 ๆฏซไผๆ—ถโ€ฆ</p></div>
.sum{padding:.7rem .9rem;background:var(--panel2);border:1px solid var(--line);
     border-left:3px solid var(--alien);border-radius:0 11px 11px 0;margin:0 0 .7rem}
.sum-zh{display:none;border-left-color:var(--violet)}
body.show-cn .sum-zh{display:block}
let hearBtn=null;
function hearLabel(btn,t){ const l=btn.querySelector(".hlbl"); if(l) l.textContent=t;
                           else btn.textContent=t; }
function clearHear(){
  if(hearBtn){ hearBtn.classList.remove("on"); hearLabel(hearBtn,"๐Ÿ”Š Listen"); hearBtn=null; }
}
function speakSummary(btn){
  if(!window.speechSynthesis) return;
  const wasMe=(hearBtn===btn);
  window.speechSynthesis.cancel(); clearHear();
  if(wasMe || !soundOn) return;                 // second press = stop
  const host=btn.closest(".lesson, .panel");    // NOT <section> on this page type
  const p=host && host.querySelector(".sum-en p");
  if(!p) return;
  (p.textContent.match(/[^.!?]+[.!?]*/g)||[p.textContent]).forEach(ch=>{
    const t=ch.trim(); if(!t) return;
    const u=new SpeechSynthesisUtterance(t);
    if(chosenVoice) u.voice=chosenVoice;
    u.lang="en-US"; u.rate=0.9;
    window.speechSynthesis.speak(u);
  });
  hearBtn=btn; btn.classList.add("on"); hearLabel(btn,"โน Stop");
  const poll=()=>{                              // onend is unreliable when queued
    if(hearBtn!==btn) return;
    if(!speechSynthesis.speaking && !speechSynthesis.pending){ clearHear(); return; }
    setTimeout(poll,400);
  };
  setTimeout(poll,400);
}
It always reads the English, even when ไธญๆ–‡ is showing. This is an English lesson. The Chinese block exists to be understood, not listened to.

House style for summary text

Two silent failure modes. closest() must match a container that actually wraps both the button and the .sum-en. Get it wrong and the function finds no paragraph and returns without a sound or an error. And a section with a Listen button but no .sum-en p does the same.

07 ยท Vocabulary system

20โ€“25 terms. Each has three layers: a one-line definition on the card, a paragraph behind tell me more, and one sentence to say aloud.

const VOCAB=[
 {id:"vagus", en:"vagus nerve", zh:"่ฟท่ตฐ็ฅž็ป",
  df:"The main cable between the gut and the brain.",
  deep:"The vagus wanders from the brainstem down through the heart, lungs and gut โ€” "+
       "'vagus' is Latin for 'wandering'. The traffic on it is lopsided: roughly 80 to 90 "+
       "percent of its fibres carry information upward, from body to brain. โ€ฆ",
  eg:"The vagus nerve carries more signals up than down."},
 โ€ฆ
];
const V_BY_ID=Object.fromEntries(VOCAB.map(v=>[v.id,v]));
function renderVocab(){
  document.getElementById("vocabGrid").innerHTML = VOCAB.map(v=>`
    <div class="vcard" data-id="${v.id}">
      <div class="en">${v.en}</div>
      <div class="zh">${v.zh}</div>
      <div class="df">${v.df}</div>
      <div class="deep hidden" id="deep-${v.id}">
        <p>${v.deep}</p>
        <p class="egline">๐Ÿ—ฃ๏ธ <b>Say it:</b> โ€œ${v.eg}โ€
          <button class="say" onclick="speak(${JSON.stringify(v.eg)},0.86)">๐Ÿ”Š</button></p>
      </div>
      <div class="row" style="gap:.3rem">
        <button class="say" onclick="speak('${v.en}',0.85)">๐Ÿ”Š say it</button>
        <button class="say" onclick="toggleDeep('${v.id}',this)">๐Ÿ“– tell me more</button>
      </div>
    </div>`).join("");
}
function toggleDeep(id,btn){
  // classList.toggle returns true when the class was ADDED, so `hid` reads
  // "is now hidden" โ€” the ternaries look backwards but are correct
  const hid=document.getElementById("deep-"+id).classList.toggle("hidden");
  btn.textContent = hid ? "๐Ÿ“– tell me more" : "๐Ÿ“• less";
  if(!hid) speak(V_BY_ID[id].deep, 0.92);   // the deep layer is also a listening drill
}
Make the numbers load-bearing. "Your gut has 500 million neurons โ€” more than a cat's whole brain" is what makes a fourteen-year-old argue about it, and arguing is the point. A definition with no number in it is forgettable.

The grid is repeat(auto-fill,minmax(178px,1fr)), so it reflows from six columns to two without a media query.

08 ยท Listen & repeat

A hands-free carousel: the page says a word, highlights its card, then gives the student a silent window to repeat it.

let cycling=false;
async function toggleCycle(){
  cycling=!cycling;
  document.getElementById("cycleBtn").textContent = cycling ? "โน Stop" : "โ–ถ Listen & repeat";
  const cue=document.getElementById("cycleCue");
  if(!cycling){ cue.textContent=""; cue.className="cue";
    document.querySelectorAll(".vcard").forEach(c=>c.classList.remove("hl","turn")); return; }
  for(const v of VOCAB){
    if(!cycling) break;
    const card=document.querySelector(`.vcard[data-id="${v.id}"]`);
    document.querySelectorAll(".vcard").forEach(c=>c.classList.remove("hl","turn"));
    card.classList.add("hl");
    card.scrollIntoView({block:"nearest",behavior:"smooth"});
    cue.className="cue"; cue.textContent="๐Ÿ”Š listenโ€ฆ  " + v.en;
    speak(v.en,0.82);
    await sleep(1600);
    if(!cycling) break;
    card.classList.remove("hl"); card.classList.add("turn");
    cue.className="cue turn"; cue.textContent="๐Ÿ—ฃ๏ธ your turn โ€”  " + v.en;
    ding();
    await sleep(2400);
  }
  if(cycling) toggleCycle();     // ran to the end: reset the button
}

Two colours carry the state: teal .hl while the page speaks, violet .turn while the student does. The ding() marks the handover so the student does not have to watch the screen.

09 ยท Speaking drill

Five words at a time, said in any order, then graded.

const DRILL_SINK={heard:"drillHeard", live:"drillLive", btn:"drillMic",
                  onEnd:()=>{ document.getElementById("drillCheck").classList.remove("hidden"); }};

function startDrill(){
  if(!preflight()) return;
  drillPool=VOCAB.slice().sort(()=>Math.random()-0.5);
  document.getElementById("drillPanel").classList.remove("hidden");
  drillNext();
}
function drillMicToggle(){ wantsRecording ? micStop() : micStart(DRILL_SINK); }

function drillGrade(){
  micStop();
  const heard=heardText();
  const res=drillTargets.map(v=>({v, ok: wordHits(v.en,heard).every(h=>h.ok)}));
  const n=res.filter(r=>r.ok).length;
  // repaint the chips green/red, show a percentage and a meter
  n===res.length ? ding() : buzz();
}

A sink is just the set of element ids the shared recogniser should paint into. That is what lets one engine serve several consumers without any of them knowing about the others.

10 ยท Speech recognition

One lazily-created recogniser for the whole page. Every gotcha below cost real debugging โ€” keep the comments when you copy it.

const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
let recog=null, recActive=false, wantsRecording=false, recStopping=false;

function ensureRecog(){
  if(!SR) return null;
  if(recog) return recog;
  recog=new SR();
  recog.lang="en-US";
  recog.continuous=true;      // false auto-stops after ~2s of silence
  recog.interimResults=true;
  recog.onresult=e=>{ /* accumulate finals, replace interims, repaint */ };
  recog.onerror=e=>{ showMicError(e.error||"unknown"); wantsRecording=false; };
  recog.onend=()=>{
    recActive=false;
    // continuous=true STILL ends on long silence in Chrome. Restart while the
    // student has not pressed stop. recStopping hard-blocks that restart, or a
    // stop landing mid-startup loses the race and the mic never really stops.
    if(wantsRecording && !recStopping){ try{ recog.start(); recActive=true; return; }catch(e){} }
    recStopping=false;
    paintLive(false);
    if(sinkTarget && sinkTarget.onEnd) sinkTarget.onEnd();
  };
  return recog;
}
micStart() must stay synchronous. An await inside it โ€” probing getUserMedia first, say โ€” breaks the user-gesture chain, and the browser then intermittently refuses to start. It works when permission is already warm and fails otherwise, which reads to the student as "the mic button does nothing."
micStop() uses abort(), not stop(). abort() tears down a session that is still starting; stop() waits politely for one that may never finish.

Error messages are actions, not codes

const MIC_ERRORS={
  "not-allowed":["Microphone blocked",
    "Tap the padlock in the address bar and set Microphone to Allow, then try again.",
    "็‚นๅ‡ปๅœฐๅ€ๆ ็š„้”ๅฝขๅ›พๆ ‡๏ผŒๆŠŠ้บฆๅ…‹้ฃŽ่ฎพไธบโ€œๅ…่ฎธโ€๏ผŒ็„ถๅŽๅ†่ฏ•ไธ€ๆฌกใ€‚"],
  "no-speech":["I did not hear anything",
    "Speak a little louder, and keep going until you press stop.",
    "่ฏดๅคงๅฃฐไธ€็‚น๏ผŒๅนถไธ”ไธ€็›ด่ฏดๅˆฐไฝ ๆŒ‰ไธ‹ๅœๆญขไธบๆญขใ€‚"],
  "aborted":[null,null,null]          // deliberate silence: we caused this one
};

Every entry is something the student can do. preflight() covers the two cases that are not errors at all โ€” a browser with no recogniser, and a page served over plain http:// โ€” and says so before anything is clicked.

11 ยท Scoring

// Levenshtein with a FREE prefix and FREE suffix, so the target may sit
// anywhere inside a longer utterance and "um โ€ฆ ok" is not punished.
function bestEditDistance(target,heard){
  if(!target.length) return 0;
  let prev=new Array(heard.length+1).fill(0), cur=new Array(heard.length+1).fill(0);
  for(let i=1;i<=target.length;i++){
    cur[0]=i;
    for(let j=1;j<=heard.length;j++)
      cur[j]=Math.min(prev[j]+1, cur[j-1]+1, prev[j-1]+(target[i-1]===heard[j-1]?0:1));
    [prev,cur]=[cur,prev];
  }
  return Math.min.apply(null,prev);      // row 0 all zeros = free prefix
}
const accuracy=(t,h)=>clamp01(1 - bestEditDistance(norm(t),norm(h))/norm(t).length);

// per-word hit test; fuzzy above 3 characters so one slurred letter does not
// fail an otherwise correct word
function wordHits(target,heard){
  const hs=norm(heard).split(" ").filter(Boolean);
  return norm(target).split(" ").filter(Boolean).map(w=>({w,
    ok: hs.some(h => h===w ||
        (w.length>3 && Math.abs(h.length-w.length)<=1 && bestEditDistance(w,h)<=1))}));
}
const PERFECT=0.85, PARTIAL=0.65;

norm() lowercases, strips to a-z0-9, and maps digits to words โ€” the recogniser returns "9" where the target says "nine".

Be generous on purpose. A learner who says the right thing and is told they are wrong stops using the feature. Free prefix and suffix, fuzzy word matching, and a 0.85 threshold rather than 1.0 all bias toward accepting a real attempt.

12 ยท Lesson tabs

const LESSONS=[["neuron","๐Ÿ”Ž The cell"],["voltage","๐Ÿ”‹ What is voltage?"],
               ["signal","โšก The spike"],["synapse","๐Ÿ”— The gap"],
               ["regions","๐Ÿ—บ๏ธ The map"],["plastic","๐ŸŒฑ Plasticity"],
               ["gut","๐Ÿฆ  The gut brain"]];

function showLesson(id){
  LESSONS.forEach(([l])=>document.getElementById("lesson-"+l)
                          .classList.toggle("hidden", l!==id));
  document.querySelectorAll("#labTabs button")
          .forEach(b=>b.classList.toggle("on", b.dataset.l===id));

  // Animations must be stopped on the way OUT, not just started on the way in โ€”
  // a hidden rAF loop otherwise keeps running behind the next lesson.
  if(id!=="signal")  apStop();
  if(id!=="synapse") synStop();
  if(id!=="gut")     gutStop();
  if(id!=="neuron")  nrStop();

  if(id==="signal") { apBuild(); apDraw(); if(!reduceMotion && apP===0) apPlay(); }
  if(id==="neuron") { neuPick(neuCur||"dendrite"); nrBuild(); nrDraw(); }
  โ€ฆ
}

The contract: tab id X โ‡„ <div class="lesson hidden" id="lesson-X">. Visibility is purely .hidden{display:none!important}. Every lesson with a running animation needs a matching stop call.

13 ยท Interactive figures โ€” three patterns

All hand-authored inline SVG. No chart library, no canvas. Colours are var(--โ€ฆ) inside SVG attributes โ€” CSS custom properties cascade into inline SVG, so the figures re-theme for free.

A Scrubbable โ€” one scalar is the state

For anything that is a process with a timeline: an action potential, transmitter crossing a synapse, a signal going up the vagus nerve.

let apP=0, apRAF=null;

function apDraw(){
  /* build the geometry purely from apP โ€” no other state */
  const st=AP_STAGES.find(s=>apP<s[0])||AP_STAGES[AP_STAGES.length-1];
  const want=`${st[1]}<span class="cn"><br>${st[2]}</span>`;
  if(cap && cap.innerHTML!==want) cap.innerHTML=want;   // only on CHANGE
  const sc=document.getElementById("apScrub");
  if(sc && Math.abs(sc.value/1000-apP)>0.002) sc.value=Math.round(apP*1000);  // dead-band
}
function apSet(p){ apStop(); apP=clamp01(+p); apBuild(); apDraw(); }
function apPlay(){
  if(apRAF) return;
  if(apP>=1) apP=0;
  const step=()=>{
    const box=document.getElementById("apViz");
    if(!box || !box.offsetParent){ apStop(); return; }   // self-halt when hidden
    apP+=0.0055;
    if(apP>=1){ apP=1; apDraw(); apStop(); return; }
    apDraw(); apRAF=requestAnimationFrame(step);
  };
  apRAF=requestAnimationFrame(step);
}

Rules that make it work:

B Live simulation โ€” real state

For anything whose point is that it makes a decision. A timeline is the wrong model for a neuron; a decision needs inputs the student chooses.

function nrStep(){
  nrPulses.forEach(p=>p.t+=0.026);
  const arrived=nrPulses.filter(p=>p.t>=1);
  nrPulses=nrPulses.filter(p=>p.t<1);
  if(!nrSpike && nrRefr<=0)
    arrived.forEach(p=>{ nrV += p.sign>0?NR_EPSP:NR_IPSP; });

  if(nrRefr>0){ nrRefr--; nrV += (NR_REST-nrV)*0.06; }
  else if(!nrSpike) nrV += (NR_REST-nrV)*0.030;      // THE LEAK
  โ€ฆ
}
function nrIdle(){   // park the loop once everything settles
  return !nrPulses.length && !nrSpike && nrRefr<=0 && !nrRain &&
         Math.abs(nrV-NR_REST)<0.35;
}
The leak is the lesson. Charge decaying back toward rest is what makes three clicks fire the cell when they are fast and fail when they are slow. Without it the figure is just a counter. Find the equivalent decay in your subject and put it in.

Park the loop when idle rather than burning a frame callback forever, and give the figure a test hook so its physics can be driven deterministically:

window.__neurolab={ nrTick:n=>{ for(let i=0;i<n;i++) nrStep(); },
                    nrState:()=>({v:nrV, spiking:!!nrSpike, fires:nrFires}) };

C Clickable diagram โ€” discrete states

For anatomy and parts lists: the neuron's components, the brain map's eighteen regions.

<g class="npart" id="rp-broca" onclick="regPick('broca')">
  <ellipse cx="152" cy="152" rx="20" ry="12" โ€ฆ />
</g>
.npart{cursor:pointer;opacity:.75;transition:opacity .15s}
.npart:hover{opacity:1}
.npart.on{opacity:1;filter:drop-shadow(0 0 6px var(--amber))}
const REG={ broca:["Broca's area ๅธƒ่‹ฅๅกๅŒบ","Producing speech. โ€ฆ","่ฏญ่จ€ไบงๅ‡บใ€‚โ€ฆ",
                   "Broca's area is for producing speech."], โ€ฆ };
function regPick(id){
  regCur=id;
  document.querySelectorAll("#lesson-regions .npart")
          .forEach(g=>g.classList.toggle("on", g.id==="rp-"+id));
  const d=REG[id];
  document.getElementById("regInfo").innerHTML =
    `<h4>${d[0]}</h4><div class="need">${d[1]}<span class="cn"><br>${d[2]}</span></div>
     <button class="say" onclick="speak(${JSON.stringify(d[3])},0.88)">๐Ÿ”Š say it</button>`;
}
Check overlaps in code, not by eye. A diagram with a dozen small parts will silently develop collisions as you add to it. Sample every pair of bounding boxes and fail on any overlap above a few pixels in both axes โ€” it catches what a screenshot does not.

Two-state controls need a visible selected state

If JS sets an on class, CSS must render it. A control that toggles invisibly looks broken even when it works:

/* a ring, not a fill, so each button keeps its own colour coding */
.btn.on{border-color:var(--alien);box-shadow:0 0 0 2px var(--alien)}
Prefer two explicit buttons to one toggle. A toggle has to label itself with either its current state or its next action, and whichever you pick, half your readers will read it as the other. "Measure on the outside / Measure on the inside" with the active one ringed removes the ambiguity entirely.

14 ยท Media and Spark images

Media never goes in git. Images, video and audio live in s3://krueng.ai/. HTML deploys through the GitHub repo and only through it. Never aws s3 cp an HTML file.
KindS3 pathReferenced from HTML as
Imagess3://krueng.ai/imgs/../imgs/โ€ฆ
Videos3://krueng.ai/video/../video/โ€ฆ
Audios3://krueng.ai/audio/../audio/โ€ฆ

Generating imagery

All generated visuals go through python/video_gen/spark_media.py โ€” SDXL and Wan 2.2 running on the DGX Spark over LAN HTTP. No API keys, no cost, nothing leaving the house.

import spark_media
spark_media.t2i(prompt, out)                 # SDXL still,        ~40โ€“60s
spark_media.i2v(still, prompt, out)          # image โ†’ video,     ~15 min
spark_media.clip(prompt, out)                # t2i then i2v
Diffusion models are unreliable for labelled schematics. Three attempts at "a myelinated axon" produced a spinal-cord cross-section, two hairy caterpillars, and a power strip. A diagram is specified geometry โ€” there is nothing in it for a sampler to reinterpret, and every reinterpretation is simply wrong. Draw those; generate atmosphere.

When you do generate, name the failure in the negative prompt rather than the positive one. A positive "no cilia" tends to summon cilia; the negative gives CFG something concrete to push away from.

Publicly available media

15 ยท The explainer video

Three to five minutes, narrated, bilingual subtitles, built by a generic script from a per-page config. Do not fork the builder.

python build_page_explainer.py neuro_lab

Content lives in python/video_gen/page_explainers/<name>.py:

VOICE = "en-US-AriaNeural"
RATE  = "-6%"

LINES = [
 ("Many axons are wrapped in myelin, a fatty sheath with gaps along it.",
  "่ฎธๅคš่ฝด็ชๅŒ…่ฃน็€้ซ“้ž˜โ€”โ€”ไธ€ๅฑ‚ๅธฆๆœ‰้—ด้š™็š„่„‚่ดจ้ž˜ใ€‚",
  ("figure", "myelin", "Myelin and the nodes of Ranvier")),
 โ€ฆ
]
FIGURES = {"myelin": draw_myelin}      # a PIL function, for anything that must be right
Visual kindUse for
("card", eyebrow, headline)A typographic slide. Numbers, contrasts, statements.
("photo", filename, caption)A real photograph already credited on the page.
("spark", prompt, caption[, avoid])An SDXL still. Optional 4th element appends to the negative prompt.
("figure", key, caption)A drawn diagram โ€” dispatches to FIGURES[key] in the same config.

The builder narrates every line first, measures the real audio with ffprobe, and sizes each Ken Burns clip to match โ€” which is why the subtitles land exactly. Timings are derived from rendered audio, never estimated. TTS output is cached, so editing a picture and rebuilding leaves every cue's timing untouched and the VTTs already on S3 stay valid.

Embedding it

<video class="hero" controls preload="none" playsinline
       poster="../video/neuro_lab_explainer_poster.jpg">
  <source src="../video/neuro_lab_explainer.mp4" type="video/mp4">
  <track kind="subtitles" src="../video/neuro_lab_explainer.en.vtt" srclang="en"
         label="English" default>
  <track kind="subtitles" src="../video/neuro_lab_explainer.zh.vtt" srclang="zh" label="ไธญๆ–‡">
  Your browser cannot play this video.
</video>

preload="none" plus a poster, so a student who never scrolls to it pays nothing. playsinline for iOS.

Encode for mobile. -profile:v main -level 4.0 -movflags +faststart -pix_fmt yuv420p. Default ffmpeg output decodes on a PC and is silently refused by Android and by the LINE in-app player.

After re-uploading a video, invalidate CloudFront or the old one keeps serving:

aws cloudfront create-invalidation --distribution-id E2F1VRCSRT1FEV \
    --paths "/video/<name>.mp4" --profile claude-deploy

17 ยท Build checklist

  1. Ground the subject. Build a NotebookLM notebook and run a deep research sweep. Count the source titles afterwards โ€” the importer double-imports.
  2. Write the vocabulary first, 20โ€“25 terms, three layers each. Everything downstream uses these words.
  3. Copy neuro_lab.html. Keep the head CSS, the chrome, and the whole script from speak() down to the scoring block. Replace VOCAB, LESSONS, and the lesson divs.
  4. Draft the lessons โ€” 5โ€“8, each with a summary in both languages, a Listen button, one interactive figure, and one or two <details> for the deeper material.
  5. Build the figures. Pick pattern A, B or C per lesson. Wire every animated one into showLesson()'s stop list.
  6. Write the narration config and build the video. Upload the mp4, both VTTs and a poster to s3://krueng.ai/video/.
  7. Add a Points to Remember panel โ€” the recap, both languages.
  8. Verify in a browser, driving the figures through the test hook rather than eyeballing them. Check: no console errors, every tab renders, every scrubber moves geometry and captions, every rAF loop stops on lesson exit, the language toggle reaches everything, and no SVG text overflows its viewBox.
  9. Commit to a feature branch and push. Never to main, never HTML to S3.

18 ยท Gotchas

SymptomCause
Animation keeps running behind another lesson Missing stop call in showLesson(). Belt and braces: also self-halt on !box.offsetParent.
Slider judders while dragging No dead-band on the two-way sync. Only write sc.value when it differs by more than ~0.002.
Page stutters during an animation A caption is being rewritten every frame. Cache the phase key and write only on change.
Listen button does nothing, no error closest() does not match a container holding the .sum-en p.
Mic button does nothing, intermittently An await crept into micStart() and broke the gesture chain.
Mic will not stop, or bleeds into the next command stop() instead of abort(), or a missing recStopping guard.
A control toggles but nothing changes on screen JS sets .on and no CSS renders it.
A control works but users say it does not Something prominent nearby has the value hardcoded. Every number that depends on a control must be derived from it.
Video plays on desktop, silently fails on Android Wrong H.264 profile. -profile:v main -level 4.0 -movflags +faststart.
Updated video still shows the old one CloudFront cache. Invalidate the path.
Thai text collides with itself Needs line-height:1.85; tone marks stack above and below the baseline.
The general lesson from most of these: a feature that works but cannot be seen to work is indistinguishable from a broken one. Visible state beats correct state that has to be inferred.

Reference implementation: neuro_lab.html ยท Earlier relative: gene_lab.html (two-player, three languages) ยท Related page type: prehistory.html (article with per-section summaries).