professor-pip / frontend.html
ratandeep's picture
Tour: compact uniform tiles, centered content (fix layout)
8e42a2e verified
Raw
History Blame Contribute Delete
52.5 kB
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script>
// Surface any JS failure into the status chip instead of failing silently.
window.addEventListener("error", function (e) {
var n = document.getElementById("status");
if (n) n.textContent = "js error: " + (e.message || e.type);
});
window.addEventListener("unhandledrejection", function (e) {
var n = document.getElementById("status");
var r = e.reason && (e.reason.message || e.reason);
if (n) n.textContent = "js error: " + r;
});
</script>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js/+esm",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/",
"talkinghead": "https://cdn.jsdelivr.net/npm/@met4citizen/talkinghead@1.7.0/modules/talkinghead.mjs"
}}
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght,SOFT@9..144,500..900,40&family=Nunito:wght@600;700;800;900&display=swap" rel="stylesheet">
<style>
:root{
--sky-top:#ffe3c7; --sky-mid:#ecdcfb; --sky-bot:#d8f3ea;
--paper:#fff7ec; --paper2:#fdeede; --ink:#2a2250; --muted:#6b6293; --line:#efe5f3;
--honey:#ffb02e; --honey-d:#e08a12; --coral:#ff7a66; --coral-d:#bb3a2c;
--teal:#27b0c4; --teal-d:#127684; --violet:#9b7be3; --violet-d:#7253bd;
--green:#3fae73; --green-d:#26794e; --gold:#ffd56a; --gold-d:#e6b53e; --good:#3fae73;
--tw1:#241c4e; --tw2:#46337e;
--display:"Fraunces",Georgia,"Times New Roman",serif;
--body:"Nunito",system-ui,-apple-system,sans-serif;
}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;color:var(--ink);font-family:var(--body);
background:linear-gradient(165deg,var(--sky-top) 0%,var(--sky-mid) 54%,var(--sky-bot) 100%);}
h1,h2,h3{font-family:var(--display);font-optical-sizing:auto;margin:0}
button{font-family:var(--body);cursor:pointer;border:none}
:focus-visible{outline:3px solid var(--teal);outline-offset:2px;border-radius:8px}
.eyebrow{font-weight:900;font-size:12px;letter-spacing:.2em;text-transform:uppercase;color:#0e6a78}
#stage{display:flex;gap:18px;padding:18px;box-sizing:border-box;height:100%}
/* ---- Pip's window (signature) ---- */
#avatar-wrap{flex:1.25;position:relative;border-radius:28px;overflow:hidden;min-height:62vh;
background:radial-gradient(125% 95% at 50% 112%, #6a4fb0 0%, var(--tw2) 34%, var(--tw1) 80%);
box-shadow:0 22px 54px rgba(58,40,110,.26), inset 0 0 0 2px rgba(255,213,106,.5), inset 0 0 70px rgba(0,0,0,.34);}
#avatar-mount{position:absolute;inset:0;z-index:2}
.win-moon{position:absolute;top:30px;right:38px;width:56px;height:56px;border-radius:50%;z-index:1;
background:radial-gradient(circle at 35% 35%,#fff7e0,#ffe6a6);box-shadow:0 0 34px rgba(255,224,150,.55)}
.win-star{position:absolute;z-index:1;color:#fff;opacity:.8;filter:drop-shadow(0 0 5px rgba(255,231,160,.8));
animation:tw 3.4s ease-in-out infinite}
@keyframes tw{0%,100%{opacity:.22;transform:scale(.8)}50%{opacity:1;transform:scale(1.12)}}
.win-glow{position:absolute;left:50%;bottom:-70px;transform:translateX(-50%);width:360px;height:360px;
border-radius:50%;z-index:1;background:radial-gradient(circle,rgba(255,196,120,.5),rgba(255,176,46,0) 62%)}
#hud{position:absolute;left:12px;bottom:12px;z-index:5;display:flex;gap:10px;align-items:center;
font:11px/1.2 ui-monospace,Menlo,monospace;background:rgba(20,12,40,.5);
padding:5px 10px;border-radius:10px;color:#d8d2ea;max-width:calc(100% - 24px);opacity:.65}
#hud #status{color:#ffd9a8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#hud .lat b{color:#fff}
#stars{position:absolute;right:12px;top:12px;z-index:5;display:flex;align-items:center;gap:6px;
background:rgba(255,247,236,.94);color:#b8860b;font-weight:900;font-size:21px;
padding:6px 15px;border-radius:999px;box-shadow:0 6px 16px rgba(40,20,80,.25)}
#stars.pop{animation:pop .5s ease}
@keyframes pop{0%,100%{transform:scale(1)}40%{transform:scale(1.35)}}
#mute{position:absolute;left:12px;top:12px;z-index:5;width:42px;height:42px;border-radius:999px;
background:rgba(255,247,236,.94);font-size:20px;cursor:pointer;box-shadow:0 6px 16px rgba(40,20,80,.22);
display:flex;align-items:center;justify-content:center}
/* ---- Panel / screens ---- */
#panel{flex:1;min-width:344px;display:flex;flex-direction:column}
.screen{display:none;flex:1;flex-direction:column;gap:16px;min-height:0}
.screen.show{display:flex}
.hidden{display:none !important}
/* Splash */
#splash{align-items:center;justify-content:center;text-align:center}
.splash-card{background:var(--paper);border-radius:30px;padding:40px 32px;max-width:430px;
box-shadow:0 18px 44px rgba(58,40,110,.2);border:1.5px solid #f2deac}
.splash-emoji{font-size:70px;line-height:1}
.splash-card h1{font-family:var(--display);font-weight:900;font-size:46px;color:var(--ink);margin:8px 0 4px;font-variation-settings:"SOFT" 40}
.splash-card p{margin:0 0 24px;color:var(--muted);font-size:18px;font-weight:700}
/* Buttons */
.big{background:var(--honey);color:var(--ink);font-size:21px;font-weight:900;padding:15px 26px;border-radius:18px;
box-shadow:0 6px 0 var(--honey-d);transition:transform .05s,box-shadow .05s}
.big:active{transform:translateY(3px);box-shadow:0 3px 0 var(--honey-d)}
.big:disabled{background:#d8cfe0;box-shadow:0 6px 0 #bcb0cc;cursor:default;color:#8c82a6}
.big.blue{background:var(--teal);box-shadow:0 6px 0 var(--teal-d)}
.big.blue:active{box-shadow:0 3px 0 var(--teal-d)}
.ghost{background:var(--paper);color:var(--muted);font-size:15px;font-weight:800;padding:10px 16px;border-radius:14px;border:2px solid var(--line)}
.ghost:active{transform:translateY(2px)}
.hand{background:var(--coral);color:var(--ink);font-size:17px;font-weight:900;padding:13px 18px;border-radius:16px;
box-shadow:0 5px 0 var(--coral-d);touch-action:none;user-select:none;-webkit-user-select:none}
.hand.rec{background:#ff4d6d;box-shadow:0 5px 0 #d63354;animation:pulse 1.1s ease-in-out infinite}
@keyframes pulse{0%,100%{box-shadow:0 5px 0 #d63354,0 0 0 0 rgba(255,77,109,.5)}50%{box-shadow:0 5px 0 #d63354,0 0 0 12px rgba(255,77,109,0)}}
/* Home */
#home-head{display:flex;align-items:flex-end;justify-content:space-between;gap:12px}
#home-head h2{font-family:var(--display);font-weight:900;font-size:29px;color:var(--ink);margin:2px 0 0;font-variation-settings:"SOFT" 30}
#btn-book{background:var(--gold);color:#6a4a00;font-weight:900;font-size:15px;padding:10px 16px;border-radius:14px;box-shadow:0 5px 0 var(--gold-d)}
#btn-book:active{transform:translateY(2px);box-shadow:0 3px 0 var(--gold-d)}
#course-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:13px}
.card{position:relative;background:var(--paper);border-radius:20px;padding:15px 16px 16px;cursor:pointer;
box-shadow:0 8px 20px rgba(58,40,110,.1);border:1.5px solid #f0e7da;overflow:hidden;
transition:transform .12s,box-shadow .12s}
.card:hover{transform:translateY(-4px) rotate(-.4deg);box-shadow:0 16px 30px rgba(58,40,110,.16)}
.card .spine{position:absolute;left:0;top:14px;bottom:14px;width:6px;border-radius:0 6px 6px 0;background:var(--c,#cdb4e0)}
.card .got{position:absolute;top:10px;right:12px;font-size:16px}
.card-emoji{font-size:42px;line-height:1}
.card-title{font-family:var(--display);font-weight:700;font-size:18px;margin:6px 0 7px;color:var(--ink);font-variation-settings:"SOFT" 40}
.card-age{margin-top:2px}
.chip{display:inline-block;font-family:var(--body);font-weight:800;font-size:12px;padding:3px 10px;border-radius:999px;background:#f1ebff;color:var(--violet-d)}
.subj-science{--c:var(--teal)} .subj-nature{--c:var(--green)} .subj-math{--c:var(--coral)}
.subj-custom{--c:var(--violet)} .subj-story{--c:var(--honey)} .subj-other{--c:#bdb2d6}
.subj-science .chip{background:#e2f6f9;color:var(--teal-d)} .subj-nature .chip{background:#e7f5ec;color:var(--green-d)}
.subj-math .chip{background:#ffe9e4;color:var(--coral-d)} .subj-custom .chip{background:#f1ebff;color:var(--violet-d)}
.subj-story .chip{background:#fff1dc;color:var(--honey-d)} .subj-other .chip{background:#efe9f5;color:var(--muted)}
/* Splash info button + About screen */
.splash-btns{display:flex;align-items:center;justify-content:center;gap:12px}
.iby{width:48px;height:48px;border-radius:50%;border:2px solid var(--honey);background:var(--paper);color:var(--ink);font-size:23px;font-weight:900;cursor:pointer;line-height:1;flex:none}
.iby:disabled{opacity:.4;cursor:default}
.iby:hover:not(:disabled){background:var(--honey);transform:translateY(-1px)}
#about-head{display:flex;align-items:flex-end;justify-content:space-between;gap:12px}
/* align-content:start stops the auto-rows from stretching to fill the tall panel
(which left content jammed at the top with a big empty area below). Tiles are
now a compact uniform height with their content vertically centred. */
#about-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:20px;align-content:start;overflow-y:auto;flex:1;min-height:0;padding:6px}
#about-grid .feat{cursor:default;display:flex;flex-direction:column;justify-content:center;min-height:152px;padding:18px 18px;transition:transform .2s,box-shadow .2s}
#about-grid .feat:hover{transform:none;box-shadow:0 8px 20px rgba(58,40,110,.1)}
#about-grid .feat .card-emoji{font-size:50px}
#about-grid .feat .card-title{font-size:17px;margin:10px 0 9px}
#about-grid .feat .card-age{margin-top:0}
/* highlight matches the "Let's explore!" button (var(--honey)); ring hugs the edge so it can't touch neighbours */
#about-grid .feat.lit{transform:translateY(-3px) scale(1.02);box-shadow:0 0 0 3px var(--honey),0 14px 30px rgba(255,176,46,.45)}
#make{background:linear-gradient(180deg,var(--paper),var(--paper2));border:2px dashed var(--teal);border-radius:20px;padding:16px}
#make h3{font-family:var(--display);font-weight:700;color:var(--teal-d);font-size:20px;margin:0 0 10px}
#make .row{display:flex;gap:10px}
#make-input{flex:1;font-family:var(--body);font-weight:700;font-size:16px;padding:13px 15px;border-radius:14px;border:2px solid #ece2f5;background:#fff;color:var(--ink)}
#make-input::placeholder{color:#b6abce}
#make-msg{margin-top:10px;color:var(--muted);font-size:15px;font-weight:800;min-height:20px}
/* Lesson */
#lesson{gap:14px}
#lesson-head{display:flex;align-items:center;gap:12px}
#lesson-title{font-family:var(--display);font-weight:700;font-size:22px;flex:1;color:var(--ink);font-variation-settings:"SOFT" 40}
#progress{display:flex;gap:6px}
.dot{width:11px;height:11px;border-radius:50%;background:var(--line)}
.dot.done{background:var(--green)}
.dot.now{background:var(--honey);transform:scale(1.3)}
#caption-box{flex:1;background:var(--paper);border-radius:24px;padding:26px;border:1.5px solid #f0e7da;
box-shadow:0 10px 26px rgba(58,40,110,.12);display:flex;align-items:center;
font-family:var(--body);font-size:27px;line-height:1.5;font-weight:800;color:var(--ink);overflow-y:auto;min-height:130px}
#quiz{display:flex;flex-direction:column;gap:10px}
#quiz .q{font-family:var(--display);font-size:22px;font-weight:700;font-variation-settings:"SOFT" 40}
#quiz .choices{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.choice{background:var(--teal);color:var(--ink);font-size:19px;font-weight:900;padding:16px;border-radius:16px;box-shadow:0 5px 0 var(--teal-d)}
.choice:active{transform:translateY(3px);box-shadow:0 2px 0 var(--teal-d)}
.choice.wrong{background:#ff9a8a;box-shadow:0 5px 0 #e0584a}
.choice.right{background:var(--green);box-shadow:0 5px 0 var(--green-d)}
.choice:disabled{opacity:.9;cursor:default}
#lesson-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
#lesson-controls .spacer{flex:1}
/* Sticker Book */
#book-head{display:flex;align-items:center;gap:12px}
#book-title{font-family:var(--display);font-weight:700;font-size:22px;flex:1}
#book-count{background:var(--gold);color:#6a4a00;font-weight:900;border-radius:999px;padding:6px 15px;box-shadow:0 4px 0 var(--gold-d)}
#book-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;overflow-y:auto;align-content:start;padding:2px}
.sticker{background:var(--paper);border-radius:18px;padding:13px 10px;text-align:center;border:1.5px solid #f0e7da;box-shadow:0 6px 14px rgba(58,40,110,.08);transition:transform .12s}
.sticker:hover{transform:translateY(-3px) rotate(-.6deg)}
.sticker .emoji{font-size:40px;line-height:1}
.sticker .name{font-family:var(--body);font-size:12px;font-weight:800;margin-top:5px;color:var(--ink)}
.sticker.locked{background:#efe9f5;border:2px dashed #d4c9e4}
.sticker.locked .emoji{filter:grayscale(1);opacity:.3}
.sticker.locked .name{color:#a99fbf;font-weight:700}
.sticker.make{background:#e6f7fa;border:2px dashed var(--teal);cursor:pointer}
.sticker.make .name{color:var(--teal-d)}
/* Earn */
#earn{align-items:center;justify-content:center;text-align:center}
.earn-card{background:radial-gradient(120% 95% at 50% 0,#fff,var(--paper2));border-radius:26px;padding:28px 24px;max-width:440px;width:100%;
box-shadow:0 18px 44px rgba(58,40,110,.2);border:1.5px solid #f2deac}
.earn-label{font-family:var(--body);font-weight:900;font-size:13px;letter-spacing:.16em;text-transform:uppercase;color:var(--coral-d)}
.earn-sticker{font-size:84px;line-height:1;margin:6px 0;display:inline-block;animation:stickerPop .6s cubic-bezier(.2,1.4,.4,1)}
@keyframes stickerPop{0%{transform:scale(0) rotate(-30deg);opacity:0}100%{transform:scale(1) rotate(-6deg);opacity:1}}
.earn-name{font-family:var(--display);font-weight:800;font-size:26px;font-variation-settings:"SOFT" 40}
.earn-stars{font-size:30px;letter-spacing:6px;margin:8px 0}
.earn-recap{background:#fff;border-radius:14px;padding:12px 14px;display:inline-block;font-family:var(--body);font-weight:700;font-size:15px;color:var(--ink);box-shadow:0 4px 12px rgba(58,40,110,.1)}
.earn-who-row{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:14px;font-family:var(--body);font-weight:800;color:var(--ink)}
.earn-who-row input{font-family:var(--body);font-weight:700;font-size:15px;padding:6px 10px;border-radius:10px;border:1.5px solid #f2deac;max-width:160px;text-align:center;background:#fff}
.earn-btns{display:flex;gap:10px;justify-content:center;margin-top:18px;flex-wrap:wrap}
/* Confetti */
#confetti{position:fixed;inset:0;pointer-events:none;overflow:hidden;z-index:50}
.confetti-bit{position:absolute;top:-40px;animation:fall linear forwards}
@keyframes fall{to{transform:translateY(110vh) rotate(540deg);opacity:.85}}
@media (max-width:920px){
#stage{flex-direction:column;height:auto}
#avatar-wrap{min-height:46vh}
#course-grid{grid-template-columns:1fr}
#about-grid{grid-template-columns:1fr}
#book-grid{grid-template-columns:repeat(3,1fr)}
}
@media (prefers-reduced-motion:reduce){
.win-star,.earn-sticker,#stars.pop{animation:none}
.win-star{opacity:.6}
.hand.rec{animation:none}
.confetti-bit{display:none}
.card:hover{transform:none}
}
</style>
</head>
<body>
<div id="stage">
<div id="avatar-wrap">
<div class="win-moon"></div>
<span class="win-star" style="top:60px;left:64px;font-size:13px"></span>
<span class="win-star" style="top:122px;left:180px;font-size:10px;animation-delay:.6s"></span>
<span class="win-star" style="top:84px;left:280px;font-size:12px;animation-delay:1.1s"></span>
<span class="win-star" style="top:190px;left:110px;font-size:9px;animation-delay:1.7s"></span>
<span class="win-star" style="top:220px;left:250px;font-size:12px;animation-delay:.4s"></span>
<div class="win-glow"></div>
<div id="avatar-mount"></div>
<div id="stars"><span id="star-count">0</span></div>
<button id="mute" title="Sound on/off">🔊</button>
<div id="hud">
<span id="status">booting…</span>
<span class="lat">ears <b id="lat-ears"></b></span>
<span class="lat">brain <b id="lat-brain"></b></span>
<span class="lat">voice <b id="lat-voice"></b></span>
</div>
</div>
<div id="panel">
<!-- Splash -->
<div id="splash" class="screen show">
<div class="splash-card">
<div class="splash-emoji">🔭</div>
<div class="eyebrow">✦ a window of wonders</div>
<h1>Professor Pip</h1>
<p>Peek into Pip's world and explore a wonder.</p>
<div class="splash-btns">
<button id="btn-start" class="big" disabled>Loading…</button>
<button id="btn-about" class="iby" disabled title="What makes Pip special?">&#9432;</button>
</div>
</div>
</div>
<!-- Home -->
<div id="home" class="screen">
<div id="home-head">
<div>
<div class="eyebrow">✦ a window of wonders</div>
<h2>Which wonder shall we explore?</h2>
</div>
<button id="btn-book">📔 My Stickers</button>
</div>
<div id="course-grid"></div>
<div id="make">
<h3>✨ Dream up a lesson</h3>
<div class="row">
<input id="make-input" placeholder="Anything you wonder about…" autocomplete="off"/>
<button id="make-btn" class="big blue">Make it</button>
</div>
<div id="make-msg"></div>
</div>
</div>
<!-- Lesson -->
<div id="lesson" class="screen">
<div id="lesson-head">
<button id="btn-home" class="ghost">← Menu</button>
<span id="lesson-title"></span>
<div id="progress"></div>
</div>
<div id="caption-box"><span id="caption"></span></div>
<div id="quiz" class="hidden"></div>
<div id="lesson-controls">
<button id="btn-hand" class="hand">🙋 Ask me!</button>
<button id="btn-pause" class="ghost">⏸ Pause</button>
<span class="spacer"></span>
<button id="btn-replay" class="ghost hidden">↺ Say again</button>
<button id="btn-next" class="big">Skip ▶</button>
</div>
</div>
<!-- Sticker Book -->
<div id="book" class="screen">
<div id="book-head">
<button id="btn-book-back" class="ghost">← Menu</button>
<span id="book-title">📔 My Sticker Book</span>
<span id="book-count">0 / 0</span>
</div>
<div id="book-grid"></div>
</div>
<!-- Earn (end of lesson) -->
<div id="earn" class="screen">
<div class="earn-card">
<div class="earn-label">NEW STICKER UNLOCKED!</div>
<div id="earn-sticker" class="earn-sticker">🌈</div>
<div id="earn-name" class="earn-name"></div>
<div id="earn-stars" class="earn-stars"></div>
<div class="earn-recap">Today I learned: <b id="earn-recap"></b></div>
<div class="earn-who-row"><span>🎓 For:</span><input id="earn-who" maxlength="20" placeholder="your name" autocomplete="off"/></div>
<div class="earn-btns">
<button id="earn-save" class="ghost">💾 Save my certificate</button>
<button id="earn-book" class="ghost">📔 See my book</button>
<button id="earn-again" class="ghost">↺ Play again</button>
<button id="earn-menu" class="big">🏠 Menu</button>
</div>
</div>
</div>
<!-- About / What makes Pip special -->
<div id="about" class="screen">
<div id="about-head">
<div>
<div class="eyebrow">✦ a window of wonders</div>
<h2>What makes Pip special</h2>
</div>
<button id="about-back" class="ghost">← Back</button>
</div>
<div id="about-grid"></div>
<button id="about-go" class="big">Let's explore! →</button>
</div>
</div>
</div>
<div id="confetti"></div>
<script>
// ---- Injected by app.py ----
const AVATAR_SOURCES = __AVATAR_SOURCES__;
const COURSES = __COURSES__;
const SYSTEM = __SYSTEM__;
const VOICE = __VOICE_DEFAULT__;
// These preflight URLs must stay in lockstep with the <script type="importmap">
// versions above (three 0.180.0, talkinghead 1.7.0) — the dynamic import of
// "talkinghead" resolves via the import map, so a version bump in only one
// place would preflight one URL while importing another.
const TALKINGHEAD_URL = "https://cdn.jsdelivr.net/npm/@met4citizen/talkinghead@1.7.0/modules/talkinghead.mjs";
const GRADIO_CLIENT_URL = "https://cdn.jsdelivr.net/npm/@gradio/client/+esm";
const THREE_URL = "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js/+esm";
let TalkingHead, Client;
const HF_TOKEN = (() => {
try {
const m = (window.parent.location.hash || "").match(/hf_token=(hf_[A-Za-z0-9]+)/);
return m ? m[1] : null;
} catch (e){ return null; }
})();
const ROOT = (() => {
try { return window.parent.location.origin; } catch (e) { return window.location.origin; }
})();
const $ = (id) => document.getElementById(id);
const esc = (s) => { const d = document.createElement("div"); d.textContent = s == null ? "" : s; return d.innerHTML; };
let head, client, rec, parts, PREMADE = [];
// Lesson state machine (lives in the browser)
const state = { course: null, idx: 0, lessonStars: 0, speakToken: 0, quizTries: 0, busy: false, recording: false, autoplay: true, paused: false, asking: false };
const ADVANCE_GAP_MS = 1000; // beat between auto-advancing segments
function status(s){ $("status").textContent = s; }
function lat(id, ms){ $(id).textContent = (ms == null) ? "—" : Math.round(ms) + " ms"; }
function showScreen(name){
["splash","home","lesson","book","earn","about"].forEach(s => $(s).classList.toggle("show", s === name));
}
function addStar(n){
n = n || 1; store.stars += n; state.lessonStars += n; saveStore();
$("star-count").textContent = store.stars;
const b = $("stars"); b.classList.remove("pop"); void b.offsetWidth; b.classList.add("pop");
}
async function callApi(name, data){
const r = await client.predict(name, data);
return r.data[0];
}
// ---- Persistence (localStorage; no accounts, no PII) ----
const STORE_KEY = "pip.v1";
function loadStore(){
try { return Object.assign({ stickers:{}, stars:0, muted:false, name:"" }, JSON.parse(localStorage.getItem(STORE_KEY) || "{}")); }
catch (e){ return { stickers:{}, stars:0, muted:false, name:"" }; }
}
function saveStore(){ try { localStorage.setItem(STORE_KEY, JSON.stringify(store)); } catch (e){} }
const store = loadStore();
let lastEarn = null; // data for the downloadable "Today I Learned" certificate
const STICKER_NAMES = {
"sky-blue":"Rainbow Maker", "photosynthesis":"Plant Chef", "odd-even":"Sock Sorter",
"water-cycle":"Rain Rider", "day-night":"Earth Spinner",
"butterfly":"Butterfly Buddy", "bees-honey":"Honey Helper", "five-senses":"Sense Detective",
"shapes":"Shape Spotter", "magnets":"Magnet Master"
};
function stickerFor(c){ return { emoji: c.emoji || "⭐", name: STICKER_NAMES[c.id] || c.title || "My Lesson" }; }
// ---- Sound (synthesized Web Audio; respects mute; uses the unlocked head.audioCtx) ----
function tone(freq, start, dur, type, gain){
if (store.muted || !head || !head.audioCtx) return;
const ctx = head.audioCtx, t0 = ctx.currentTime + start;
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = type || "sine"; o.frequency.value = freq;
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(gain || 0.18, t0 + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
o.connect(g); g.connect(ctx.destination); o.start(t0); o.stop(t0 + dur + 0.03);
}
const sfx = {
click(){ tone(420, 0, 0.08, "triangle", 0.10); },
ding(){ [660,880,1320].forEach((f,i) => tone(f, i*0.07, 0.20, "sine", 0.16)); },
sparkle(){ [880,1175,1568,2093].forEach((f,i) => tone(f, i*0.06, 0.18, "triangle", 0.13)); },
boop(){ tone(300, 0, 0.16, "sine", 0.12); tone(235, 0.10, 0.20, "sine", 0.10); },
tada(){ [523,659,784,1047].forEach((f,i) => tone(f, i*0.12, 0.45, "triangle", 0.16)); tone(1047, 0.50, 0.5, "sine", 0.12); }
};
// ---- Confetti (emoji burst; no library) ----
function confetti(n, emojis){
const layer = $("confetti"); if (!layer) return;
const set = emojis || ["⭐","✨","🌈","🎉","💫"];
for (let i = 0; i < n; i++){
const b = document.createElement("div");
b.className = "confetti-bit";
b.textContent = set[Math.floor(Math.random() * set.length)];
b.style.left = Math.round(Math.random() * 100) + "%";
b.style.animationDuration = (1.4 + Math.random() * 1.2) + "s";
b.style.animationDelay = (Math.random() * 0.2) + "s";
b.style.fontSize = (20 + Math.random() * 20) + "px";
layer.appendChild(b);
setTimeout(() => b.remove(), 3000);
}
}
function setMute(m){ store.muted = m; saveStore(); const el = $("mute"); if (el) el.textContent = m ? "🔇" : "🔊"; }
// ---- Sticker Book screen ----
function openBook(){
const grid = $("book-grid"); grid.innerHTML = "";
const slots = PREMADE.slice();
Object.keys(store.stickers).forEach(id => {
if (!slots.some(c => c.id === id)) slots.push(Object.assign({ id: id, custom: true }, store.stickers[id]));
});
let earned = 0;
slots.forEach(c => {
const s = c.custom ? { emoji: c.emoji, name: c.name } : stickerFor(c);
const d = document.createElement("div");
if (store.stickers[c.id]){ earned++; d.className = "sticker";
d.innerHTML = '<div class="emoji">' + esc(s.emoji) + '</div><div class="name">' + esc(s.name) + '</div>'; }
else { d.className = "sticker locked";
d.innerHTML = '<div class="emoji">' + esc(s.emoji) + '</div><div class="name">? locked</div>'; }
grid.appendChild(d);
});
const mk = document.createElement("div");
mk.className = "sticker make";
mk.innerHTML = '<div class="emoji">✨</div><div class="name">Make your own!</div>';
mk.onclick = () => { sfx.click(); showScreen("home"); const mi = $("make-input"); if (mi) mi.focus(); };
grid.appendChild(mk);
$("book-count").textContent = earned + " / " + slots.length + " ⭐ " + store.stars;
showScreen("book");
}
// ---- Audio unlock (browsers require a user gesture) ----
function audioReady(){ return head && head.audioCtx && head.audioCtx.state === "running"; }
function unlockAudio(){
try {
if (!head || !head.audioCtx) return;
if (head.audioCtx.state !== "running") head.audioCtx.resume();
const b = head.audioCtx.createBuffer(1, 1, head.audioCtx.sampleRate || 24000);
const s = head.audioCtx.createBufferSource();
s.buffer = b; s.connect(head.audioCtx.destination); s.start(0);
if (audioReady()) document.removeEventListener("pointerdown", unlockAudio, true);
} catch (e) {}
}
async function ensureAudio(){
unlockAudio();
if (audioReady()) return;
await new Promise(res => {
const h = () => { unlockAudio(); if (audioReady()){ document.removeEventListener("pointerdown", h, true); res(); } };
document.addEventListener("pointerdown", h, true);
});
}
// ---- Speech, cancel-safe ----
// Cut any in-flight speech: bump the token (so pending awaits bail) AND stop
// the avatar's current audio/lipsync. Call before starting any new speech.
function stopSpeech(){
state.speakToken++;
try { if (head && head.stopSpeaking) head.stopSpeaking(); } catch (e) {}
}
// ---- Audio cache + prefetch (fetch+decode once, play from memory = instant) ----
const audioCache = new Map(); // text -> {buf, words, wtimes, wdurations} | in-flight Promise
function getAudio(text){
const existing = audioCache.get(text);
if (existing) return Promise.resolve(existing); // resolved rec OR pending promise
const p = (async () => {
try {
const out = JSON.parse(await callApi("/speak", [text, VOICE]));
if (out.error) throw new Error(out.error);
const bytes = Uint8Array.from(atob(out.audio_b64), c => c.charCodeAt(0));
const buf = await head.audioCtx.decodeAudioData(bytes.buffer);
const rec = { buf: buf, words: out.words, wtimes: out.wtimes, wdurations: out.wdurations };
audioCache.set(text, rec);
return rec;
} catch (e){ audioCache.delete(text); throw e; } // failed -> retryable
})();
audioCache.set(text, p);
return p;
}
function prefetch(text){ if (text) getAudio(text).catch(() => {}); }
// Voice one line; resolves when it finishes. Plays from the in-memory cache when
// ready (instant); otherwise fetches once and caches. Bails at every await if a
// newer stopSpeech()/speak() superseded this one (token mismatch).
async function speak(text, mood, gesture){
if (!text) return;
const myToken = state.speakToken;
status("voicing…");
let rec;
try {
const t0 = performance.now();
rec = await getAudio(text);
lat("lat-voice", performance.now() - t0);
} catch (e){ if (myToken === state.speakToken) status("voice error"); return; }
if (myToken !== state.speakToken) return;
await ensureAudio();
if (myToken !== state.speakToken) return;
let durMs = 1200;
try {
durMs = rec.buf.duration * 1000 + 250;
if (mood && head.setMood) head.setMood(mood);
if (gesture && head.playGesture) head.playGesture(gesture, 3);
head.speakAudio({ audio: rec.buf, words: rec.words, wtimes: rec.wtimes, wdurations: rec.wdurations }, { lipsyncLang: "en" });
status("speaking…");
} catch (e){ status("audio error: " + (e && e.message ? e.message : e)); durMs = 600; }
await new Promise(r => setTimeout(r, durMs));
if (myToken === state.speakToken) status("ready");
}
// Guarded one-shot line (greeting, praise, replay, reject): stops prior
// speech, owns state.busy, and is cancel-safe. False if it was superseded.
async function say(text, mood, gesture){
stopSpeech();
const token = state.speakToken;
state.busy = true;
try { await speak(text, mood, gesture); }
finally { if (token === state.speakToken) state.busy = false; }
return token === state.speakToken;
}
// ---- Home screen ----
function renderHome(){
const grid = $("course-grid");
grid.innerHTML = "";
COURSES.forEach(c => {
const card = document.createElement("div");
card.className = "card subj-" + (c.subject || "other");
const subj = c.subject || "lesson";
const subjCap = subj.charAt(0).toUpperCase() + subj.slice(1);
card.innerHTML = '<span class="spine"></span>' +
(store.stickers[c.id] ? '<span class="got">⭐</span>' : '') +
'<div class="card-emoji">' + esc(c.emoji || "📘") + '</div>' +
'<div class="card-title">' + esc(c.title) + '</div>' +
'<div class="card-age"><span class="chip">' + esc(subjCap) + ' · Ages ' + esc(c.age_band || "5-8") + '</span></div>';
card.onclick = () => { sfx.click(); startCourse(c); };
grid.appendChild(card);
});
}
// ---- About / "What makes Pip special" tour (avatar narrates ~35s) ----
const ABOUT_INTRO = { text: "Hi! I'm Professor Pip. Peek into my world and see what makes me special!", mood: "happy", gesture: "handup" };
const ABOUT_OUTRO = { text: "That's me! Ready to explore? Let's go!", mood: "love", gesture: "thumbup" };
const ABOUT_FEATURES = [
{ emoji: "🪄", title: "Live in your browser", sub: "3D avatar · 60fps · zero GPU on the face", subj: "science", say: "My face is drawn right inside your screen, sixty times a second!", mood: "happy", gesture: "index" },
{ emoji: "🗣️", title: "I talk and listen", sub: "voice lessons + raise-your-hand", subj: "story", say: "I speak every lesson, and you can ask me anything out loud!", mood: "happy", gesture: "side" },
{ emoji: "🧠", title: "A tiny clever brain", sub: "fine-tuned MiniCPM-1B", subj: "math", say: "My brain is teeny-tiny, taught specially to teach just like me!", mood: "happy", gesture: "index" },
{ emoji: "🎭", title: "I really emote", sub: "the model picks my mood + gestures", subj: "custom", say: "My little brain even picks how I smile and wave my hands while I talk!", mood: "happy", gesture: "handup" },
{ emoji: "⚡", title: "Powered by Modal", sub: "trained and served with llama.cpp", subj: "science", say: "I was trained, and I live, up in the Modal cloud!", mood: "happy", gesture: "side" },
{ emoji: "📚", title: "Ten lessons and your own", sub: "premade courses + make-your-own", subj: "nature", say: "Pick from ten lessons, or dream up your very own!", mood: "happy", gesture: "index" },
{ emoji: "🎉", title: "Stars, stickers & a certificate", sub: "quizzes · confetti · a keepsake certificate", subj: "story", say: "Win stars, stickers, and confetti, and take home a certificate of all you learned!", mood: "love", gesture: "thumbup" },
{ emoji: "🔒", title: "Kind and safe", sub: "server-side safety · no data kept", subj: "nature", say: "And I only ever talk about kind, safe, happy things!", mood: "happy", gesture: "namaste" },
];
function renderAbout(){
const grid = $("about-grid"); grid.innerHTML = "";
ABOUT_FEATURES.forEach((f, i) => {
const card = document.createElement("div");
card.className = "card feat subj-" + f.subj;
card.dataset.idx = i;
card.innerHTML = '<span class="spine"></span>' +
'<div class="card-emoji">' + esc(f.emoji) + '</div>' +
'<div class="card-title">' + esc(f.title) + '</div>' +
'<div class="card-age"><span class="chip">' + esc(f.sub) + '</span></div>';
grid.appendChild(card);
});
}
function litTile(i){
document.querySelectorAll("#about-grid .feat").forEach(el => el.classList.toggle("lit", Number(el.dataset.idx) === i));
}
async function narrateAbout(){
stopSpeech();
const token = state.speakToken;
state.busy = true;
try {
await speak(ABOUT_INTRO.text, ABOUT_INTRO.mood, ABOUT_INTRO.gesture);
for (let i = 0; i < ABOUT_FEATURES.length; i++){
if (state.speakToken !== token) return;
litTile(i);
const f = ABOUT_FEATURES[i];
await speak(f.say, f.mood, f.gesture);
}
if (state.speakToken !== token) return;
litTile(-1);
await speak(ABOUT_OUTRO.text, ABOUT_OUTRO.mood, ABOUT_OUTRO.gesture);
} finally {
if (state.speakToken === token){ state.busy = false; litTile(-1); }
}
}
function startAbout(){
if (typeof unlockAudio === "function") unlockAudio();
showScreen("about");
renderAbout();
narrateAbout();
}
async function makeOwn(){
const topic = $("make-input").value.trim();
if (!topic) return;
const btn = $("make-btn");
btn.disabled = true;
$("make-msg").textContent = "Professor Pip is writing your lesson and recording her voice…";
try {
const out = JSON.parse(await callApi("/make_course", [topic]));
if (out.rejected){
$("make-msg").textContent = out.message;
say(out.message, "happy", "shrug");
} else {
$("make-msg").textContent = "";
$("make-input").value = "";
if (!COURSES.some(c => c.id === out.id)){ COURSES.push(out); renderHome(); }
startCourse(out);
}
} catch (e){
$("make-msg").textContent = "Oops, let's try that again in a moment.";
} finally { btn.disabled = false; }
}
// ---- Lesson player ----
function renderProgress(){
const p = $("progress"); p.innerHTML = "";
state.course.segments.forEach((_, i) => {
const d = document.createElement("div");
d.className = "dot" + (i < state.idx ? " done" : i === state.idx ? " now" : "");
p.appendChild(d);
});
}
function hideLessonControls(){ // per-segment UI only; Ask/Pause/Skip stay visible
$("btn-replay").classList.add("hidden");
$("quiz").classList.add("hidden"); $("quiz").innerHTML = "";
}
function caption(text){ $("caption").textContent = text; }
async function startCourse(c){
cancelAsk();
stopSpeech();
state.course = c; state.idx = 0; state.lessonStars = 0; state.busy = false;
setPaused(false);
["btn-hand","btn-pause","btn-next"].forEach(id => $(id).classList.remove("hidden"));
$("lesson-title").textContent = c.title;
showScreen("lesson");
c.segments.forEach(s => prefetch(s.say)); // pre-fetch + decode every line up front
if (c.recap) prefetch(c.recap);
await ensureAudio();
playSegment(0);
}
async function playSegment(i){
const c = state.course;
if (i >= c.segments.length) return finishCourse();
state.idx = i; state.quizTries = 0;
const seg = c.segments[i];
renderProgress();
hideLessonControls();
caption(seg.say);
if (c.segments[i + 1]) prefetch(c.segments[i + 1].say); // next line ready while this one plays
stopSpeech();
const token = state.speakToken;
state.busy = true;
try { await speak(seg.say, seg.mood, seg.gesture); }
finally { if (token === state.speakToken) state.busy = false; }
if (token !== state.speakToken) return; // superseded (pause / ask / skip / menu)
$("btn-replay").classList.remove("hidden");
if (seg.quiz){ showQuiz(seg.quiz); return; } // quiz pauses auto-play until answered
if (state.autoplay && !state.paused && !state.asking){
setTimeout(() => { // auto-advance after a beat (cancel-safe via token)
if (token === state.speakToken && !state.paused && !state.asking) playSegment(i + 1);
}, ADVANCE_GAP_MS);
}
}
function showQuiz(quiz){
const box = $("quiz");
box.classList.remove("hidden");
box.innerHTML = '<div class="q">' + esc(quiz.question) + '</div>';
const choices = document.createElement("div");
choices.className = "choices";
quiz.choices.forEach(ch => {
const b = document.createElement("button");
b.className = "choice"; b.textContent = ch;
b.onclick = () => answerQuiz(b, ch, quiz);
choices.appendChild(b);
});
box.appendChild(choices);
}
async function answerQuiz(btn, choice, quiz){
if (state.busy || state.paused) return;
const buttons = [...btn.parentElement.querySelectorAll("button")];
buttons.forEach(b => b.disabled = true);
stopSpeech();
const token = state.speakToken;
state.busy = true;
try {
if (choice === quiz.answer){
btn.classList.add("right");
addStar();
sfx.ding(); confetti(16, ["⭐","✨","🎉"]);
await speak("Yes! That's right! Great job!", "love", "thumbup");
if (token !== state.speakToken) return;
playSegment(state.idx + 1); // bumps token + owns busy from here
} else {
btn.classList.add("wrong");
sfx.boop();
state.quizTries++;
if (state.quizTries < 2){
await speak("So close! Let's try once more.", "happy", "shrug");
if (token !== state.speakToken) return;
buttons.forEach(b => { if (!b.classList.contains("wrong")) b.disabled = false; });
} else {
buttons.forEach(b => { if (b.textContent === quiz.answer) b.classList.add("right"); });
await speak("Good try! The answer is " + quiz.answer + ". Now you know!", "happy", "index");
if (token !== state.speakToken) return;
playSegment(state.idx + 1);
}
}
} finally {
if (token === state.speakToken) state.busy = false;
}
}
async function finishCourse(){
hideLessonControls();
const c = state.course;
const recap = c.recap || "You finished the whole lesson! Amazing work!";
addStar(2); // completion bonus
const s = stickerFor(c);
store.stickers[c.id] = { emoji: s.emoji, name: s.name, recap: recap };
saveStore();
// Fill + show the earn screen with the sticker pop
$("earn-sticker").textContent = s.emoji;
$("earn-name").textContent = s.name;
$("earn-stars").textContent = "⭐".repeat(Math.max(1, Math.min(state.lessonStars, 5)));
$("earn-recap").textContent = recap.length > 160 ? recap.slice(0, 160) + "…" : recap;
lastEarn = { title: c.title || s.name, emoji: s.emoji, sticker: s.name, recap: recap, stars: Math.max(1, Math.min(state.lessonStars, 5)) };
$("earn-who").value = store.name || "";
showScreen("earn");
confetti(44); sfx.tada(); setTimeout(() => sfx.sparkle(), 320);
// Pip celebrates
stopSpeech();
const token = state.speakToken;
state.busy = true;
try {
if (head.setMood) head.setMood("love");
if (head.playGesture) head.playGesture("thumbup", 4);
await speak("Hooray! You finished the lesson and earned the " + s.name + " sticker! I'm so proud of you!", "love", "handup");
} finally { if (token === state.speakToken) state.busy = false; }
}
// ---- Downloadable "Today I Learned" certificate (canvas -> PNG, all client-side) ----
function _rr(x,a,b,w,h,r){ x.beginPath(); x.moveTo(a+r,b); x.arcTo(a+w,b,a+w,b+h,r); x.arcTo(a+w,b+h,a,b+h,r); x.arcTo(a,b+h,a,b,r); x.arcTo(a,b,a+w,b,r); x.closePath(); }
function _wrap(x,text,cx,cy,maxW,lh){
const words=String(text).split(" "); let line="", y=cy;
for (const w of words){ const t=line+w+" "; if (x.measureText(t).width>maxW && line){ x.fillText(line.trim(),cx,y); line=w+" "; y+=lh; } else line=t; }
x.fillText(line.trim(),cx,y); return y;
}
function saveCertificate(){
sfx.click();
const e = lastEarn; if (!e){ return; }
const W=1080, H=1350, cv=document.createElement("canvas"); cv.width=W; cv.height=H;
const x=cv.getContext("2d");
const g=x.createLinearGradient(0,0,0,H); g.addColorStop(0,"#ffe3c7"); g.addColorStop(.5,"#ecdcfb"); g.addColorStop(1,"#d8f3ea");
x.fillStyle=g; x.fillRect(0,0,W,H);
x.fillStyle="#fff7ec"; _rr(x,70,90,W-140,H-180,40); x.fill();
x.lineWidth=8; x.strokeStyle="#ffb02e"; _rr(x,70,90,W-140,H-180,40); x.stroke();
x.textAlign="center";
x.fillStyle="#2a2250"; x.font="800 42px Nunito,Arial,sans-serif"; x.fillText("Professor Pip", W/2, 214);
x.fillStyle="#ff7a66"; x.font="700 26px Nunito,Arial,sans-serif"; x.fillText("CERTIFICATE OF LEARNING", W/2, 258);
x.font="190px serif"; x.fillText(e.emoji, W/2, 480);
x.fillStyle="#2a2250"; x.font="800 56px Nunito,Arial,sans-serif"; x.fillText(e.sticker, W/2, 570);
x.font="700 32px Nunito,Arial,sans-serif"; x.fillText("Awarded to", W/2, 648);
x.font="800 58px Georgia,serif"; x.fillText((store.name||"a clever explorer").slice(0,20), W/2, 716);
x.font="40px serif"; x.fillText("⭐".repeat(e.stars), W/2, 792);
x.fillStyle="#2a2250"; x.font="700 30px Nunito,Arial,sans-serif"; _wrap(x, "Today I learned: "+e.recap, W/2, 884, W-240, 42);
x.fillStyle="#6b5fa0"; x.font="600 26px Nunito,Arial,sans-serif"; x.fillText(e.title, W/2, H-186);
x.font="600 22px Nunito,Arial,sans-serif"; x.fillText("Professor Pip · build-small-hackathon", W/2, H-132);
cv.toBlob(b => { const a=document.createElement("a"); a.href=URL.createObjectURL(b); a.download="professor-pip-certificate.png"; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),5000); });
if (sfx.sparkle) sfx.sparkle();
}
function nextSegment(){ playSegment(state.idx + 1); }
// ---- Ask (tap-to-talk) + Pause ----
let askStream = null, askAnalyser = null, askMaxTimer = null;
function resetAskBtn(){ const b = $("btn-hand"); b.classList.remove("rec"); b.textContent = "🙋 Ask me!"; }
function setPaused(p){
state.paused = p;
$("btn-pause").textContent = p ? "▶ Play" : "⏸ Pause";
}
function cancelAsk(){ // abort an in-progress ask without sending it (Skip / Pause / Menu)
if (askMaxTimer){ clearTimeout(askMaxTimer); askMaxTimer = null; }
askAnalyser = null;
state.asking = false;
if (rec && rec.state === "recording"){ rec.onstop = null; try { rec.stop(); } catch (e){} }
if (askStream){ askStream.getTracks().forEach(t => t.stop()); askStream = null; }
resetAskBtn();
}
function listenForSilence(stream){ // auto-send once the child goes quiet after speaking
try {
const ctx = head.audioCtx;
const src = ctx.createMediaStreamSource(stream);
askAnalyser = ctx.createAnalyser();
askAnalyser.fftSize = 512;
src.connect(askAnalyser); // not connected to destination -> no echo
const data = new Uint8Array(askAnalyser.fftSize);
let heard = false, quietSince = 0;
const poll = () => {
if (!state.asking || !askAnalyser) return;
askAnalyser.getByteTimeDomainData(data);
let sum = 0;
for (let k = 0; k < data.length; k++){ const v = (data[k] - 128) / 128; sum += v * v; }
const rms = Math.sqrt(sum / data.length);
const now = performance.now();
if (rms > 0.05){ heard = true; quietSince = 0; }
else if (heard){
if (!quietSince) quietSince = now;
else if (now - quietSince > 1200){ stopAsk(); return; } // quiet after speech -> send
}
requestAnimationFrame(poll);
};
requestAnimationFrame(poll);
} catch (e){ /* silence detection is best-effort */ }
}
async function startAsk(){
if (state.asking) return;
state.asking = true;
stopSpeech(); // pause the flow + cancel any pending auto-advance
state.busy = false;
const btn = $("btn-hand");
btn.classList.add("rec"); btn.textContent = "● Listening… tap to send";
status("listening…");
let stream;
try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); }
catch (err){ state.asking = false; resetAskBtn(); status("mic blocked — open the direct URL (⋯ → Embed)"); return; }
if (!state.asking){ stream.getTracks().forEach(t => t.stop()); return; } // cancelled while awaiting mic
askStream = stream;
const mime = (window.MediaRecorder && MediaRecorder.isTypeSupported("audio/webm")) ? "audio/webm" : "";
try {
parts = [];
rec = new MediaRecorder(stream, mime ? { mimeType: mime } : {});
rec.ondataavailable = ev => parts.push(ev.data);
rec.onstop = onAskStop;
rec.start();
} catch (e){ cancelAsk(); status("mic error"); return; } // tear down stream + clear asking
listenForSilence(stream);
askMaxTimer = setTimeout(() => stopAsk(), 10000); // safety cap
}
function stopAsk(){
if (askMaxTimer){ clearTimeout(askMaxTimer); askMaxTimer = null; }
askAnalyser = null;
if (rec && rec.state === "recording") rec.stop(); // -> onAskStop (send)
else cancelAsk(); // not recording yet -> abort
}
async function onAskStop(){
const token = state.speakToken; // honor the cancel-token invariant like other continuations
if (askStream){ askStream.getTracks().forEach(t => t.stop()); askStream = null; }
resetAskBtn();
const blob = new Blob(parts, { type: rec.mimeType || "audio/webm" });
const b64 = await new Promise(res => { const fr = new FileReader(); fr.onload = () => res(fr.result); fr.readAsDataURL(blob); });
status("listening…");
let q = "";
try {
const t0 = performance.now();
q = await callApi("/asr", [b64, blob.type || ""]);
lat("lat-ears", performance.now() - t0);
} catch (e){ status("ears error"); }
q = (q || "").trim();
if (!state.asking || token !== state.speakToken) return; // cancelled (menu/skip/pause) while transcribing
if (!q){ state.asking = false; if (!state.paused) playSegment(state.idx); return; } // nothing heard -> resume
await answerQuestion(q);
}
async function answerQuestion(question){
const seg = state.course.segments[state.idx];
stopSpeech();
const token = state.speakToken;
state.busy = true;
try {
// Server-side safety gate (app.brain) screens the child's question and the
// reply; an unsafe turn comes back as a gentle redirect.
const ctx = 'We are in a lesson called "' + state.course.title + '". You just said: "' +
seg.say + '". A child asks: "' + question + '". Answer the child simply and warmly in your own voice, then we will keep learning.';
status("thinking…");
const t0 = performance.now();
const out = JSON.parse(await callApi("/brain", [SYSTEM, JSON.stringify([{ role: "user", content: ctx }]), 120]));
lat("lat-brain", performance.now() - t0);
if (token !== state.speakToken) return;
caption(out.text);
await speak(out.text, out.mood, out.gesture);
if (token !== state.speakToken) return;
await speak("Okay, let's keep learning!", "happy", "handup");
} catch (e){ status("brain error"); }
finally { if (token === state.speakToken) state.busy = false; }
if (token !== state.speakToken) return; // superseded (menu / pause / skip during the answer)
state.asking = false;
if (!state.paused) playSegment(state.idx); // replay the interrupted line + resume auto-play
}
// ---- Avatar load + boot (unchanged sequence) ----
async function loadAvatar(){
for (const s of AVATAR_SOURCES){
try {
status("avatar: trying " + s.label + "…");
const pre = await fetch(s.url, { cache: "force-cache" });
if (!pre.ok) throw new Error("HTTP " + pre.status);
await head.showAvatar(
{ url: s.url, body: s.body || "F", avatarMood: "happy", lipsyncLang: "en" },
(p) => { if (p && p.total) status("avatar " + Math.round(100 * p.loaded / p.total) + "%"); }
);
return true;
} catch (e){ /* try next source */ }
}
status("no avatar source could load");
return false;
}
async function init(){
status("loading avatar…");
head = new TalkingHead($("avatar-mount"), {
ttsEndpoint: "none", lipsyncModules: ["en"], cameraView: "upper",
cameraRotateEnable: false, lightAmbientIntensity: 2, lightDirectIntensity: 24
});
// Unlock audio on the first gesture anywhere (self-removes once running).
document.addEventListener("pointerdown", unlockAudio, true);
if (!(await loadAvatar())) return;
status("connecting engine…");
client = await Client.connect(ROOT, HF_TOKEN ? { hf_token: HF_TOKEN } : {});
// Wire UI
renderHome();
PREMADE = COURSES.slice(); // premade set (before make-your-own pushes)
$("star-count").textContent = store.stars;
setMute(store.muted);
$("mute").onclick = () => { setMute(!store.muted); if (!store.muted) sfx.click(); };
$("btn-book").onclick = () => { sfx.click(); openBook(); };
$("btn-book-back").onclick = () => { sfx.click(); showScreen("home"); };
$("earn-book").onclick = () => { sfx.click(); openBook(); };
$("earn-menu").onclick = () => { sfx.click(); showScreen("home"); };
$("earn-again").onclick = () => { sfx.click(); startCourse(state.course); };
$("earn-save").onclick = saveCertificate;
$("earn-who").oninput = (ev) => { store.name = ev.target.value.trim().slice(0, 20); saveStore(); };
$("btn-hand").onclick = () => { sfx.click(); if (state.asking) stopAsk(); else startAsk(); };
$("btn-pause").onclick = () => {
sfx.click();
if (state.paused){ setPaused(false); cancelAsk(); playSegment(state.idx); } // resume: replay + continue
else { setPaused(true); cancelAsk(); stopSpeech(); state.busy = false; status("paused"); }
};
$("btn-next").onclick = () => { sfx.click(); cancelAsk(); setPaused(false); playSegment(state.idx + 1); }; // Skip ▶
$("btn-replay").onclick = () => {
if (state.busy || state.asking) return;
sfx.click();
const s = state.course.segments[state.idx];
caption(s.say);
say(s.say, s.mood, s.gesture);
};
$("btn-home").onclick = () => { sfx.click(); cancelAsk(); setPaused(false); stopSpeech(); state.busy = false; showScreen("home"); };
$("make-btn").onclick = () => { sfx.click(); makeOwn(); };
$("make-input").addEventListener("keydown", e => { if (e.key === "Enter") makeOwn(); });
const start = $("btn-start");
start.disabled = false; start.textContent = "▶ Tap to start";
start.onclick = () => {
unlockAudio();
showScreen("home");
say("Hi friend! I'm Professor Pip. Pick a lesson, or make your own. Let's learn together!", "happy", "handup");
};
const about = $("btn-about");
about.disabled = false;
about.onclick = () => { sfx.click(); startAbout(); };
$("about-back").onclick = () => { sfx.click(); stopSpeech(); state.busy = false; litTile(-1); showScreen("splash"); };
$("about-go").onclick = () => { sfx.click(); stopSpeech(); state.busy = false; litTile(-1); showScreen("home"); say("Hi friend! Pick a lesson, or make your own. Let's learn together!", "happy", "handup"); };
// Pre-decode the tour lines during splash idle so the ⓘ tour plays gap-free.
prefetch(ABOUT_INTRO.text); ABOUT_FEATURES.forEach(f => prefetch(f.say)); prefetch(ABOUT_OUTRO.text);
status("ready — tap to start");
}
async function preflight(label, url){
const r = await fetch(url, { method: "GET", cache: "force-cache" });
if (!r.ok) throw new Error(label + " HTTP " + r.status);
}
async function boot(){
try {
status("loading modules…");
await preflight("three.js", THREE_URL);
await preflight("talkinghead", TALKINGHEAD_URL);
await preflight("gradio client", GRADIO_CLIENT_URL);
TalkingHead = (await import("talkinghead")).TalkingHead;
Client = (await import(GRADIO_CLIENT_URL)).Client;
await init();
} catch (e){
const msg = (e && e.message) ? e.message : String(e);
status("boot failed: " + msg);
}
}
boot();
</script>
</body>
</html>