diff --git a/app/services/tutor_service.py b/app/services/tutor_service.py
index d1e2936..fb3a31b 100644
--- a/app/services/tutor_service.py
+++ b/app/services/tutor_service.py
@@ -41,7 +41,16 @@
C --> D[Rerank]
```
Keep diagrams small (5-10 nodes is plenty). Always pair the diagram with one short follow-up question or a single specific hint — the diagram is a teaching aid, not a replacement for the conversation.
-- Don't draw a diagram for every reply. Skip it when the answer is a one-line concept, a syntax lookup, or a quick yes/no. The bar: would a real tutor reach for the whiteboard here? If not, stay in prose."""
+- Don't draw a diagram for every reply. Skip it when the answer is a one-line concept, a syntax lookup, or a quick yes/no. The bar: would a real tutor reach for the whiteboard here? If not, stay in prose.
+- When the explanation is a multi-step PROCESS (a pipeline, a state machine, a data flow, an algorithm walkthrough) and building it up stage by stage would help more than one static picture, use a narrated whiteboard instead of a plain diagram. Emit a fenced block tagged lt-narrated whose body is JSON with a "steps" array; each step has a one-sentence "say" line and its own CUMULATIVE "mermaid" source (step N draws nodes 1..N). 3-6 steps. Produce the whole JSON in the SAME reply (one pass — never promise to send it next):
+ ```lt-narrated
+ {"steps":[
+ {"say":"First the query is embedded.","mermaid":"flowchart LR\\n Q[Query]-->E[Embed]"},
+ {"say":"Then we search the vector store.","mermaid":"flowchart LR\\n Q[Query]-->E[Embed]-->S[Search]"},
+ {"say":"Finally the top hits are reranked.","mermaid":"flowchart LR\\n Q[Query]-->E[Embed]-->S[Search]-->R[Rerank]"}
+ ]}
+ ```
+ Use the plain ```mermaid path for a single static diagram; use lt-narrated only when the step-by-step build is the teaching point. Still pair it with one short follow-up question or hint. A narrated block plus its one question is a deliberate exception to the "2-4 sentences / one hint" rule above — when you do reach for it, give it the full 3-6 steps rather than truncating to stay brief."""
_TRIAGE_JUDGE_PROMPT = """You sit between a learner working on a graded coding assignment and an AI coding agent the learner can talk to. Your one job: decide which side handles the learner's next prompt.
diff --git a/app/static/lab-tutor.css b/app/static/lab-tutor.css
index c60a5cf..99cde1a 100644
--- a/app/static/lab-tutor.css
+++ b/app/static/lab-tutor.css
@@ -392,3 +392,111 @@
white-space: pre-wrap;
word-break: break-word;
}
+
+/* Narrated whiteboard */
+.lt-narrated {
+ margin: 8px 0;
+ padding: 12px;
+ background: #fafafa;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+}
+.lt-narrated-stage {
+ opacity: 0;
+ transform: translateY(10px) scale(0.96);
+}
+.lt-narrated-stage--in {
+ opacity: 1;
+ transform: none;
+ transition: opacity 0.32s cubic-bezier(0.22, 1, 0.36, 1),
+ transform 0.32s cubic-bezier(0.22, 1, 0.36, 1);
+}
+.lt-narrated-spot {
+ animation: lt-narrated-pulse 0.9s ease-out 1;
+}
+@keyframes lt-narrated-pulse {
+ 0% {
+ filter: drop-shadow(0 0 0 rgba(31, 111, 235, 0));
+ transform: scale(1);
+ }
+ 35% {
+ filter: drop-shadow(0 0 6px rgba(31, 111, 235, 0.75));
+ transform: scale(1.06);
+ }
+ 100% {
+ filter: drop-shadow(0 0 0 rgba(31, 111, 235, 0));
+ transform: scale(1);
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .lt-narrated-stage,
+ .lt-narrated-stage--in {
+ opacity: 1;
+ transform: none;
+ transition: none;
+ }
+ .lt-narrated-spot {
+ animation: none;
+ }
+}
+.lt-narrated-stage svg {
+ max-width: 100%;
+ height: auto;
+ display: block;
+ margin: 0 auto;
+}
+.lt-narrated-caption {
+ margin-top: 8px;
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--lt-text);
+ min-height: 1.5em;
+}
+.lt-narrated-dots {
+ display: flex;
+ gap: 6px;
+ margin-top: 8px;
+}
+.lt-narrated-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--lt-border);
+ transition: background 0.15s ease, transform 0.15s ease;
+}
+.lt-narrated-dot--active {
+ background: var(--lt-accent, #1f6feb);
+ transform: scale(1.4);
+}
+@media (prefers-reduced-motion: reduce) {
+ .lt-narrated-dot {
+ transition: none;
+ }
+}
+.lt-narrated-controls {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 10px;
+}
+.lt-narrated-btn {
+ padding: 4px 10px;
+ border: 1px solid var(--lt-border);
+ background: var(--lt-surface);
+ border-radius: 6px;
+ font-size: 12px;
+ font-family: var(--lt-font);
+ color: var(--lt-text);
+ cursor: pointer;
+ transition: background 0.1s ease;
+}
+.lt-narrated-btn:hover:not(:disabled) {
+ background: var(--lt-surface-2);
+}
+.lt-narrated-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+.lt-narrated-controls .lt-narrated-btn:first-child {
+ margin-right: auto;
+}
diff --git a/app/static/lab-tutor.js b/app/static/lab-tutor.js
index 43a632a..ccccabb 100644
--- a/app/static/lab-tutor.js
+++ b/app/static/lab-tutor.js
@@ -10,6 +10,206 @@
(function () {
"use strict";
+ // ── Narrated-whiteboard pure helpers (also unit-tested under Node) ─────────
+ // Escape literal newline/carriage-return/tab chars that appear INSIDE JSON
+ // string literals (LLMs often emit raw newlines in multiline values like
+ // mermaid source). Structural whitespace between tokens is left untouched.
+ function escapeControlCharsInStrings(src) {
+ let out = "";
+ let inStr = false;
+ let esc = false;
+ for (let i = 0; i < src.length; i++) {
+ const ch = src[i];
+ if (esc) { out += ch; esc = false; continue; }
+ if (ch === "\\" && inStr) { out += ch; esc = true; continue; }
+ if (ch === '"') { inStr = !inStr; out += ch; continue; }
+ if (inStr && ch === "\n") { out += "\\n"; continue; }
+ if (inStr && ch === "\r") { out += "\\r"; continue; }
+ if (inStr && ch === "\t") { out += "\\t"; continue; }
+ out += ch;
+ }
+ return out;
+ }
+
+ // Parse an lt-narrated block body into { steps: [{say, mermaid}] } or null.
+ function parseNarrated(body) {
+ function tryParse(s) {
+ try { return JSON.parse(s); } catch { return undefined; }
+ }
+ let obj = tryParse(body);
+ if (obj === undefined) {
+ let repaired = String(body);
+ const open = repaired.indexOf("{");
+ const close = repaired.lastIndexOf("}");
+ if (open !== -1 && close > open) repaired = repaired.slice(open, close + 1);
+ repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); // trailing commas
+ repaired = escapeControlCharsInStrings(repaired); // raw \n \r \t in values
+ obj = tryParse(repaired);
+ }
+ if (!obj || !Array.isArray(obj.steps)) return null;
+ const steps = obj.steps.filter(
+ (st) =>
+ st &&
+ typeof st.say === "string" &&
+ st.say.trim() !== "" &&
+ typeof st.mermaid === "string" &&
+ st.mermaid.trim() !== ""
+ ).map((st) => ({ say: st.say, mermaid: st.mermaid }));
+ return steps.length > 0 ? { steps } : null;
+ }
+
+ // No-audio / muted pacing: max(2s, words*240ms) (pattern from OpenMAIC).
+ function computeNoAudioMs(say) {
+ const words = String(say).trim().split(/\s+/).filter(Boolean).length;
+ return Math.max(2000, words * 240);
+ }
+
+ // Pure playback state machine. State: {mode,step,total}.
+ // mode: "idle" | "playing" | "paused" | "done".
+ function narratedReducer(state, action) {
+ const s = state;
+ switch (action.type) {
+ case "PLAY":
+ if (s.mode === "done") return { mode: "playing", step: 0, total: s.total };
+ return { mode: "playing", step: s.step, total: s.total };
+ case "PAUSE":
+ if (s.mode !== "playing") return s;
+ return { mode: "paused", step: s.step, total: s.total };
+ case "ADVANCE": {
+ // Stale-callback guard: only the active "playing" mode advances.
+ if (s.mode !== "playing") return s;
+ const next = s.step + 1;
+ if (next >= s.total) return { mode: "done", step: s.total - 1, total: s.total };
+ return { mode: "playing", step: next, total: s.total };
+ }
+ case "NEXT": {
+ const next = Math.min(s.step + 1, s.total - 1);
+ const done = s.step + 1 >= s.total;
+ return { mode: done ? "done" : s.mode, step: next, total: s.total };
+ }
+ case "PREV":
+ return { mode: s.mode, step: Math.max(s.step - 1, 0), total: s.total };
+ case "REPLAY":
+ return { mode: "playing", step: 0, total: s.total };
+ case "STOP":
+ return { mode: "idle", step: 0, total: s.total };
+ default:
+ return s;
+ }
+ }
+
+ // Distinct human-visible node labels from a Mermaid flowchart/graph
+ // source, in declaration order. Pure; never throws. Used to spotlight
+ // the node added by the current step. Worst case (exotic markup): a
+ // label is missed and the spotlight simply no-ops — never breakage.
+ // Normalize a raw Mermaid label to the text the SVG actually renders:
+ // Mermaid strips one pair of surrounding quotes and turns
tags
+ // into line breaks (textContent has no tag). Matching the helper's
+ // output to rendered node textContent is what makes the spotlight hit.
+ function normalizeLabel(raw) {
+ let s = String(raw).trim();
+ if (s.length >= 2) {
+ const a = s[0];
+ const b = s[s.length - 1];
+ if ((a === '"' && b === '"') || (a === "'" && b === "'")) {
+ s = s.slice(1, -1);
+ }
+ }
+ s = s.replace(/
/gi, "");
+ return s.trim();
+ }
+
+ function extractNodeLabels(src) {
+ if (!src || typeof src !== "string") return [];
+ const RESERVED = new Set([
+ "flowchart", "graph", "subgraph", "end", "style", "classDef",
+ "class", "linkStyle", "click", "direction", "stateDiagram",
+ "stateDiagram-v2", "sequenceDiagram", "classDiagram", "erDiagram",
+ "journey", "gantt", "pie", "mindmap",
+ "LR", "RL", "TB", "BT", "TD",
+ ]);
+ // Most-specific bracket pairs first so e.g. ([stadium]) is not
+ // mis-read by the (round) pattern. The node-id quantifier is capped
+ // (\w{0,63}) — Mermaid ids are short identifiers, and an unbounded
+ // \w* here backtracks O(n^2) on long word-char runs (ReDoS) since
+ // this runs in the render path.
+ const SHAPES = [
+ /([A-Za-z_]\w{0,63})\s*\[\[([^\]]+)\]\]/g, // [[subroutine]]
+ /([A-Za-z_]\w{0,63})\s*\[\(([^)]+)\)\]/g, // [(cylinder)]
+ /([A-Za-z_]\w{0,63})\s*\(\(([^)]+)\)\)/g, // ((circle))
+ /([A-Za-z_]\w{0,63})\s*\(\[([^\]]+)\]\)/g, // ([stadium])
+ /([A-Za-z_]\w{0,63})\s*\{\{([^}]+)\}\}/g, // {{hexagon}}
+ /([A-Za-z_]\w{0,63})\s*\[([^\]]+)\]/g, // [rect]
+ /([A-Za-z_]\w{0,63})\s*\(([^)]+)\)/g, // (round)
+ /([A-Za-z_]\w{0,63})\s*\{([^}]+)\}/g, // {rhombus}
+ /([A-Za-z_]\w{0,63})\s*>([^\]]+)\]/g, // >asymmetric]
+ ];
+ const labelById = Object.create(null);
+ const order = [];
+ // Drop edge labels |...| so they are not parsed as node content.
+ let work = src.replace(/\|[^|]*\|/g, " ");
+ // Blank a leading diagram header line.
+ const lines = work.split(/\r?\n/);
+ if (
+ lines.length &&
+ /^\s*(flowchart|graph|stateDiagram(-v2)?|sequenceDiagram|classDiagram|erDiagram|journey|gantt|pie|mindmap)\b/.test(
+ lines[0]
+ )
+ ) {
+ lines[0] = "";
+ }
+ work = lines.join("\n");
+ // Strip
tags so they do not confuse the >asymmetric] shape
+ // regex: `C
D` would otherwise produce a spurious br>D] match.
+ // normalizeLabel already removes them from captured label text; this
+ // removes them from the source so the surrounding parse is clean too.
+ work = work.replace(/
/gi, "");
+ // Invariant: exec on the stable `work` (so captured groups stay
+ // reliable across patterns) while accumulating blanking on a separate
+ // `stripped` copy (so each successive pattern sees a progressively
+ // cleaned string). Do not swap these.
+ let stripped = work;
+ for (const re of SHAPES) {
+ re.lastIndex = 0;
+ let m;
+ while ((m = re.exec(work)) !== null) {
+ const id = m[1];
+ const label = normalizeLabel(m[2]);
+ if (!(id in labelById) && label !== "") {
+ labelById[id] = label;
+ order.push(id);
+ }
+ }
+ stripped = stripped.replace(re, " $1 ");
+ }
+ // Remove edge operators so arrowheads (x/o) are not read as ids.
+ stripped = stripped.replace(/-{1,3}[>xo]?|-\.-?>?|={2,}>?/g, " ");
+ const tokens = stripped.match(/[A-Za-z_]\w*/g) || [];
+ for (const t of tokens) {
+ if (RESERVED.has(t)) continue;
+ if (!(t in labelById)) {
+ labelById[t] = t;
+ order.push(t);
+ }
+ }
+ return order.map((id) => labelById[id]);
+ }
+
+ // Labels present in `currSrc` but not `prevSrc`. No prev (step 0) -> [].
+ function diffNewNodeLabels(prevSrc, currSrc) {
+ const curr = extractNodeLabels(currSrc);
+ if (!prevSrc) return [];
+ const prev = new Set(extractNodeLabels(prevSrc));
+ return curr.filter((l) => !prev.has(l));
+ }
+
+ // Node-only export hook for unit tests. In the browser `module` is
+ // undefined so this is skipped and the widget bootstraps normally.
+ if (typeof module !== "undefined" && module.exports) {
+ module.exports = { parseNarrated, computeNoAudioMs, narratedReducer, extractNodeLabels, diffNewNodeLabels };
+ return;
+ }
+
// ── Config from script tag ────────────────────────────────────────────────
const me = document.currentScript
|| document.querySelector('script[src*="lab-tutor.js"]');
@@ -140,6 +340,232 @@
}
}
+
+ // Render a narrated-whiteboard card: stepwise Mermaid reveal + TTS.
+ function renderNarratedInto(parent, spec) {
+ const card = document.createElement("div");
+ card.className = "lt-narrated";
+
+ const stage = document.createElement("div");
+ stage.className = "lt-narrated-stage";
+ card.appendChild(stage);
+
+ const caption = document.createElement("div");
+ caption.className = "lt-narrated-caption";
+ card.appendChild(caption);
+
+ const dots = document.createElement("div");
+ dots.className = "lt-narrated-dots";
+ dots.setAttribute("aria-hidden", "true");
+ const dotEls = [];
+ for (let d = 0; d < spec.steps.length; d++) {
+ const dot = document.createElement("span");
+ dot.className = "lt-narrated-dot";
+ dots.appendChild(dot);
+ dotEls.push(dot);
+ }
+ card.appendChild(dots);
+
+ const controls = document.createElement("div");
+ controls.className = "lt-narrated-controls";
+ const mkBtn = (label, aria) => {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "lt-narrated-btn";
+ b.textContent = label;
+ b.setAttribute("aria-label", aria);
+ return b;
+ };
+ const playBtn = mkBtn("▶ Play narration", "Play narration");
+ const prevBtn = mkBtn("‹", "Previous step");
+ const nextBtn = mkBtn("›", "Next step");
+ const muteBtn = mkBtn("🔊", "Mute narration");
+ controls.append(playBtn, prevBtn, nextBtn, muteBtn);
+ card.appendChild(controls);
+ parent.appendChild(card);
+
+ const total = spec.steps.length;
+ let state = { mode: "idle", step: 0, total };
+ let muted = false;
+ let noAudioTimer = null;
+ const synth = window.speechSynthesis || null;
+ const ttsOk = !!(synth && window.SpeechSynthesisUtterance);
+ if (!ttsOk) {
+ muteBtn.style.display = "none";
+ muted = true;
+ }
+
+ function clearTimer() {
+ if (noAudioTimer) { clearTimeout(noAudioTimer); noAudioTimer = null; }
+ }
+ function stopSpeech() {
+ clearTimer();
+ if (ttsOk) { try { synth.cancel(); } catch {} }
+ }
+
+ function spotlightNew(i) {
+ try {
+ if (i <= 0) return;
+ const labels = diffNewNodeLabels(
+ spec.steps[i - 1].mermaid,
+ spec.steps[i].mermaid
+ );
+ if (!labels.length) return;
+ let nodes = stage.querySelectorAll("g.node");
+ // '[class*="node"]' is intentionally broader than the design
+ // spec's ".nodeLabel/text" — it absorbs Mermaid version variation;
+ // textContent equality + the `hit` dedup prevent false positives.
+ if (!nodes.length) nodes = stage.querySelectorAll('[class*="node"]');
+ const wanted = new Set(labels);
+ const hit = new Set();
+ nodes.forEach((el) => {
+ const txt = (el.textContent || "").trim();
+ if (wanted.has(txt) && !hit.has(txt)) {
+ hit.add(txt);
+ el.classList.add("lt-narrated-spot");
+ setTimeout(() => {
+ try { el.classList.remove("lt-narrated-spot"); } catch {}
+ }, 1000);
+ }
+ });
+ } catch {
+ /* spotlight is a nicety — never a failure path */
+ }
+ }
+
+ async function renderStep(i) {
+ const step = spec.steps[i];
+ caption.textContent = step.say;
+ stage.classList.remove("lt-narrated-stage--in");
+ stage.innerHTML = "";
+ // reflow so the entry transition re-triggers
+ void stage.offsetWidth;
+ await renderMermaidInto(stage, step.mermaid);
+ stage.classList.add("lt-narrated-stage--in");
+ spotlightNew(i);
+ }
+
+ function syncControls() {
+ playBtn.textContent =
+ state.mode === "playing" ? "⏸ Pause"
+ : state.mode === "done" ? "↻ Replay"
+ : "▶ Play narration";
+ playBtn.setAttribute(
+ "aria-label",
+ state.mode === "playing" ? "Pause narration"
+ : state.mode === "done" ? "Replay narration"
+ : "Play narration"
+ );
+ prevBtn.disabled = state.step === 0;
+ nextBtn.disabled = state.step >= total - 1 && state.mode !== "playing";
+ for (let d = 0; d < dotEls.length; d++) {
+ dotEls[d].classList.toggle("lt-narrated-dot--active", d === state.step);
+ }
+ }
+
+ function advance() {
+ state = narratedReducer(state, { type: "ADVANCE" });
+ if (state.mode === "playing") {
+ run();
+ } else {
+ syncControls();
+ }
+ }
+
+ function speakCurrent() {
+ const step = spec.steps[state.step];
+ stopSpeech();
+ if (!muted && ttsOk) {
+ const u = new SpeechSynthesisUtterance(step.say);
+ u.onend = () => { if (state.mode === "playing") advance(); };
+ u.onerror = () => { if (state.mode === "playing") advance(); };
+ try { synth.speak(u); }
+ catch { noAudioTimer = setTimeout(() => { if (state.mode === "playing") advance(); }, computeNoAudioMs(step.say)); }
+ } else {
+ noAudioTimer = setTimeout(
+ () => { if (state.mode === "playing") advance(); },
+ computeNoAudioMs(step.say)
+ );
+ }
+ }
+
+ async function run() {
+ await renderStep(state.step);
+ syncControls();
+ if (state.mode === "playing") speakCurrent();
+ }
+
+ function doPlayPause() {
+ if (state.mode === "playing") {
+ state = narratedReducer(state, { type: "PAUSE" });
+ stopSpeech();
+ syncControls();
+ } else {
+ const wasDone = state.mode === "done";
+ state = narratedReducer(state, { type: wasDone ? "REPLAY" : "PLAY" });
+ run();
+ }
+ }
+ function doPrev() {
+ stopSpeech();
+ state = narratedReducer(state, { type: "PREV" });
+ if (state.mode === "playing") { state = narratedReducer(state, { type: "PAUSE" }); }
+ renderStep(state.step).then(syncControls);
+ }
+ function doNext() {
+ stopSpeech();
+ state = narratedReducer(state, { type: "NEXT" });
+ if (state.mode === "playing") { state = narratedReducer(state, { type: "PAUSE" }); }
+ renderStep(state.step).then(syncControls);
+ }
+
+ playBtn.addEventListener("click", doPlayPause);
+ prevBtn.addEventListener("click", doPrev);
+ nextBtn.addEventListener("click", doNext);
+ muteBtn.addEventListener("click", () => {
+ muted = !muted;
+ muteBtn.textContent = muted ? "🔇" : "🔊";
+ muteBtn.setAttribute("aria-label", muted ? "Unmute narration" : "Mute narration");
+ if (muted) {
+ stopSpeech();
+ // Muting mid-playback must not rely on cancel() firing onerror to
+ // advance — restart pacing via the no-audio timer explicitly.
+ if (state.mode === "playing") {
+ noAudioTimer = setTimeout(
+ () => { if (state.mode === "playing") advance(); },
+ computeNoAudioMs(spec.steps[state.step].say)
+ );
+ }
+ }
+ });
+
+ // Card-scoped keyboard (NEVER document-level — must not leak into
+ // code-server / Monaco / page shortcuts).
+ card.tabIndex = 0;
+ card.setAttribute("role", "group");
+ card.setAttribute("aria-label", "Narrated whiteboard");
+ card.addEventListener("keydown", (e) => {
+ const onButton = !!(e.target && e.target.tagName === "BUTTON");
+ if (e.key === " " || e.key === "Spacebar") {
+ // A focused control button activates natively on Space (keyup
+ // -> click). Defer to it; don't also fire the card shortcut
+ // (that would double-toggle / double-act).
+ if (onButton) return;
+ e.preventDefault();
+ doPlayPause();
+ } else if (e.key === "ArrowRight") {
+ e.preventDefault();
+ doNext();
+ } else if (e.key === "ArrowLeft") {
+ e.preventDefault();
+ doPrev();
+ }
+ });
+
+ // Initial paint: first step visible, idle (no autoplay).
+ renderStep(0).then(syncControls);
+ }
+
// ── SVG helpers ───────────────────────────────────────────────────────────
function chatIcon() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
@@ -376,8 +802,9 @@
const wrap = document.createElement("div");
wrap.className = "lt-msg lt-msg--tutor";
- // Split on mermaid fences. Even indices = text, odd indices = mermaid code.
- const fence = /```mermaid\n([\s\S]*?)\n```/g;
+ // Split on lt-narrated AND mermaid fences. A combined scanner keeps
+ // text/diagram/narrated ordering intact.
+ const fence = /```(lt-narrated|mermaid)\n([\s\S]*?)\n```/g;
let lastIndex = 0;
let m;
let any = false;
@@ -386,10 +813,23 @@
const before = text.slice(lastIndex, m.index);
if (before.trim()) appendParagraphsBold(wrap, before);
const host = document.createElement("div");
- host.className = "lt-mermaid-host";
wrap.appendChild(host);
- // Fire-and-forget; the placeholder appears synchronously.
- renderMermaidInto(host, m[1]);
+ if (m[1] === "lt-narrated") {
+ const spec = parseNarrated(m[2]);
+ if (spec) {
+ host.className = "lt-narrated-host";
+ renderNarratedInto(host, spec);
+ } else {
+ // Fallback: treat the raw body as a failed-mermaid-style card.
+ host.className = "lt-mermaid lt-mermaid--failed";
+ const pre = document.createElement("pre");
+ pre.textContent = m[2];
+ host.appendChild(pre);
+ }
+ } else {
+ host.className = "lt-mermaid-host";
+ renderMermaidInto(host, m[2]); // fire-and-forget
+ }
lastIndex = m.index + m[0].length;
}
const tail = text.slice(lastIndex);
diff --git a/docs/superpowers/plans/2026-05-16-narrated-whiteboard-attention-polish.md b/docs/superpowers/plans/2026-05-16-narrated-whiteboard-attention-polish.md
new file mode 100644
index 0000000..c186ee2
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-16-narrated-whiteboard-attention-polish.md
@@ -0,0 +1,682 @@
+# Narrated Whiteboard — Attention Polish Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make the shipped narrated-whiteboard card more attention-grabbing — spotlight the just-added node each step, polish the step-in motion, and add keyboard control + progress dots — with no voice/LLM/persistence/infra changes.
+
+**Architecture:** Two new pure, Node-unit-tested helpers (`extractNodeLabels`, `diffNewNodeLabels`) added to the existing guarded helper block in `app/static/lab-tutor.js` and exported for tests. `renderNarratedInto` calls them after each step's Mermaid renders and adds a transient CSS class to the SVG node whose visible label is new (best-effort, never fatal). CSS gains a spotlight keyframe + spring step-in easing + progress-dot styles. Keyboard handlers are card-scoped and reuse the existing playback handler logic.
+
+**Tech Stack:** Vanilla ES2020 (no bundler), existing vendored Mermaid, Node v24 built-in test runner (`node --test`, zero deps), CSS. No Python changes.
+
+---
+
+## File Structure
+
+- `app/static/lab-tutor.js` (modify) — add `extractNodeLabels` + `diffNewNodeLabels` to the guarded helper block and to `module.exports`; add spotlight call in `renderStep`; extract shared playback handlers; add progress dots + card-scoped keyboard.
+- `app/static/lab-tutor.css` (modify) — `.lt-narrated-spot` keyframe, spring step-in easing, `.lt-narrated-dots`/`.lt-narrated-dot` styles, reduced-motion coverage.
+- `tests/js/test_narrated.js` (modify) — Node unit tests for the two new pure helpers.
+
+No new files. No persistence/LLM/server changes. Manual browser check for DOM/CSS/keyboard (repo has no JS DOM harness — same tradeoff as the base feature).
+
+---
+
+## Task 1: Pure helpers `extractNodeLabels` + `diffNewNodeLabels`
+
+**Files:**
+- Modify: `app/static/lab-tutor.js` — insert two functions immediately BEFORE the export guard (the line ` if (typeof module !== "undefined" && module.exports) {`, currently ~line 103), and extend the `module.exports = {...}` object.
+- Test: `tests/js/test_narrated.js` (append tests at end).
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `tests/js/test_narrated.js`:
+
+```js
+
+test("extractNodeLabels: bracketed flowchart labels", () => {
+ const out = lt.extractNodeLabels("flowchart LR\n A[Query]-->B[Embed]");
+ assert.deepEqual(out.slice().sort(), ["Embed", "Query"]);
+});
+
+test("extractNodeLabels: bare ids when no label declared", () => {
+ const out = lt.extractNodeLabels("graph TD\n A-->B");
+ assert.deepEqual(out.slice().sort(), ["A", "B"]);
+});
+
+test("extractNodeLabels: ignores edge-label text and direction/header", () => {
+ const out = lt.extractNodeLabels("flowchart LR\n A[Q] -->|yes| B[E]");
+ assert.deepEqual(out.slice().sort(), ["E", "Q"]);
+ assert.equal(out.includes("yes"), false);
+ assert.equal(out.includes("LR"), false);
+ assert.equal(out.includes("flowchart"), false);
+});
+
+test("extractNodeLabels: dedupes by first declaration", () => {
+ const out = lt.extractNodeLabels("flowchart LR\n A[Q]-->B[E]\n B[E]-->A[Q]");
+ assert.deepEqual(out.slice().sort(), ["E", "Q"]);
+});
+
+test("extractNodeLabels: shape variants (rhombus/circle)", () => {
+ const out = lt.extractNodeLabels("flowchart TD\n A{Decide}-->B((Done))");
+ assert.deepEqual(out.slice().sort(), ["Decide", "Done"]);
+});
+
+test("extractNodeLabels: non-string / empty -> []", () => {
+ assert.deepEqual(lt.extractNodeLabels(""), []);
+ assert.deepEqual(lt.extractNodeLabels(null), []);
+});
+
+test("diffNewNodeLabels: no prev (step 0) -> []", () => {
+ assert.deepEqual(lt.diffNewNodeLabels(undefined, "flowchart LR\n A[Q]"), []);
+ assert.deepEqual(lt.diffNewNodeLabels("", "flowchart LR\n A[Q]"), []);
+});
+
+test("diffNewNodeLabels: returns only newly added label", () => {
+ const out = lt.diffNewNodeLabels(
+ "flowchart LR\n A[Q]-->B[E]",
+ "flowchart LR\n A[Q]-->B[E]-->C[S]"
+ );
+ assert.deepEqual(out, ["S"]);
+});
+
+test("diffNewNodeLabels: no change -> []", () => {
+ assert.deepEqual(
+ lt.diffNewNodeLabels("flowchart LR\n A[Q]", "flowchart LR\n A[Q]"),
+ []
+ );
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `node --test tests/js/test_narrated.js`
+Expected: FAIL — `lt.extractNodeLabels is not a function`.
+
+- [ ] **Step 3: Implement the helpers**
+
+In `app/static/lab-tutor.js`, immediately BEFORE this exact existing block:
+
+```js
+ // Node-only export hook for unit tests. In the browser `module` is
+ // undefined so this is skipped and the widget bootstraps normally.
+ if (typeof module !== "undefined" && module.exports) {
+ module.exports = { parseNarrated, computeNoAudioMs, narratedReducer };
+ return;
+ }
+```
+
+insert:
+
+```js
+ // Distinct human-visible node labels from a Mermaid flowchart/graph
+ // source, in declaration order. Pure; never throws. Used to spotlight
+ // the node added by the current step. Worst case (exotic markup): a
+ // label is missed and the spotlight simply no-ops — never breakage.
+ function extractNodeLabels(src) {
+ if (!src || typeof src !== "string") return [];
+ const RESERVED = new Set([
+ "flowchart", "graph", "subgraph", "end", "style", "classDef",
+ "class", "linkStyle", "click", "direction", "stateDiagram",
+ "stateDiagram-v2", "sequenceDiagram", "classDiagram", "erDiagram",
+ "journey", "gantt", "pie", "mindmap",
+ "LR", "RL", "TB", "BT", "TD", "DT",
+ ]);
+ // Most-specific bracket pairs first so e.g. ([stadium]) is not
+ // mis-read by the (round) pattern.
+ const SHAPES = [
+ /([A-Za-z_]\w*)\s*\[\[([^\]]+)\]\]/g, // [[subroutine]]
+ /([A-Za-z_]\w*)\s*\[\(([^)]+)\)\]/g, // [(cylinder)]
+ /([A-Za-z_]\w*)\s*\(\(([^)]+)\)\)/g, // ((circle))
+ /([A-Za-z_]\w*)\s*\(\[([^\]]+)\]\)/g, // ([stadium])
+ /([A-Za-z_]\w*)\s*\{\{([^}]+)\}\}/g, // {{hexagon}}
+ /([A-Za-z_]\w*)\s*\[([^\]]+)\]/g, // [rect]
+ /([A-Za-z_]\w*)\s*\(([^)]+)\)/g, // (round)
+ /([A-Za-z_]\w*)\s*\{([^}]+)\}/g, // {rhombus}
+ /([A-Za-z_]\w*)\s*>([^\]]+)\]/g, // >asymmetric]
+ ];
+ const labelById = Object.create(null);
+ const order = [];
+ // Drop edge labels |...| so they are not parsed as node content.
+ let work = src.replace(/\|[^|]*\|/g, " ");
+ // Blank a leading diagram header line.
+ const lines = work.split(/\r?\n/);
+ if (
+ lines.length &&
+ /^\s*(flowchart|graph|stateDiagram(-v2)?|sequenceDiagram|classDiagram|erDiagram|journey|gantt|pie|mindmap)\b/.test(
+ lines[0]
+ )
+ ) {
+ lines[0] = "";
+ }
+ work = lines.join("\n");
+ let stripped = work;
+ for (const re of SHAPES) {
+ re.lastIndex = 0;
+ let m;
+ while ((m = re.exec(work)) !== null) {
+ const id = m[1];
+ const label = m[2].trim();
+ if (!(id in labelById) && label !== "") {
+ labelById[id] = label;
+ order.push(id);
+ }
+ }
+ stripped = stripped.replace(re, " $1 ");
+ }
+ // Remove edge operators so arrowheads (x/o) are not read as ids.
+ stripped = stripped.replace(/-{1,3}[>xo]?|-\.-?>?|={2,}>?/g, " ");
+ const tokens = stripped.match(/[A-Za-z_]\w*/g) || [];
+ for (const t of tokens) {
+ if (RESERVED.has(t)) continue;
+ if (!(t in labelById)) {
+ labelById[t] = t;
+ order.push(t);
+ }
+ }
+ return order.map((id) => labelById[id]);
+ }
+
+ // Labels present in `currSrc` but not `prevSrc`. No prev (step 0) -> [].
+ function diffNewNodeLabels(prevSrc, currSrc) {
+ const curr = extractNodeLabels(currSrc);
+ if (!prevSrc) return [];
+ const prev = new Set(extractNodeLabels(prevSrc));
+ return curr.filter((l) => !prev.has(l));
+ }
+```
+
+Then change the export line from:
+
+```js
+ module.exports = { parseNarrated, computeNoAudioMs, narratedReducer };
+```
+
+to:
+
+```js
+ module.exports = { parseNarrated, computeNoAudioMs, narratedReducer, extractNodeLabels, diffNewNodeLabels };
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `node --test tests/js/test_narrated.js`
+Expected: PASS — all tests (the original 10 + the 9 new) pass.
+
+- [ ] **Step 5: Syntax check**
+
+Run: `node --check app/static/lab-tutor.js`
+Expected: exit 0, no output.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add app/static/lab-tutor.js tests/js/test_narrated.js
+git commit -m "feat(lab-tutor): node-label extraction + diff helpers for spotlight
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Task 2: Spotlight DOM application + motion polish CSS
+
+**Files:**
+- Modify: `app/static/lab-tutor.js` — extend `renderStep` inside `renderNarratedInto` to spotlight new nodes.
+- Modify: `app/static/lab-tutor.css` — spotlight keyframe, spring step-in, reduced-motion coverage.
+
+No automated test (DOM); covered by Task 1 pure tests + Task 4 manual verification.
+
+- [ ] **Step 1: Add the spotlight helper + call in `renderStep`**
+
+In `app/static/lab-tutor.js`, find this EXACT current `renderStep` (inside `renderNarratedInto`):
+
+```js
+ async function renderStep(i) {
+ const step = spec.steps[i];
+ caption.textContent = step.say;
+ stage.classList.remove("lt-narrated-stage--in");
+ stage.innerHTML = "";
+ // reflow so the entry transition re-triggers
+ void stage.offsetWidth;
+ await renderMermaidInto(stage, step.mermaid);
+ stage.classList.add("lt-narrated-stage--in");
+ }
+```
+
+Replace it with:
+
+```js
+ function spotlightNew(i) {
+ try {
+ if (i <= 0) return;
+ const labels = diffNewNodeLabels(
+ spec.steps[i - 1].mermaid,
+ spec.steps[i].mermaid
+ );
+ if (!labels.length) return;
+ let nodes = stage.querySelectorAll("g.node");
+ if (!nodes.length) nodes = stage.querySelectorAll('[class*="node"]');
+ const wanted = new Set(labels);
+ const hit = new Set();
+ nodes.forEach((el) => {
+ const txt = (el.textContent || "").trim();
+ if (wanted.has(txt) && !hit.has(txt)) {
+ hit.add(txt);
+ el.classList.add("lt-narrated-spot");
+ setTimeout(() => {
+ try { el.classList.remove("lt-narrated-spot"); } catch {}
+ }, 1000);
+ }
+ });
+ } catch {
+ /* spotlight is a nicety — never a failure path */
+ }
+ }
+
+ async function renderStep(i) {
+ const step = spec.steps[i];
+ caption.textContent = step.say;
+ stage.classList.remove("lt-narrated-stage--in");
+ stage.innerHTML = "";
+ // reflow so the entry transition re-triggers
+ void stage.offsetWidth;
+ await renderMermaidInto(stage, step.mermaid);
+ stage.classList.add("lt-narrated-stage--in");
+ spotlightNew(i);
+ }
+```
+
+- [ ] **Step 2: Motion polish + spotlight CSS**
+
+In `app/static/lab-tutor.css`, find this EXACT current block:
+
+```css
+.lt-narrated-stage {
+ opacity: 0;
+ transform: translateY(6px) scale(0.98);
+}
+.lt-narrated-stage--in {
+ opacity: 1;
+ transform: none;
+ transition: opacity 0.22s ease, transform 0.22s ease;
+}
+@media (prefers-reduced-motion: reduce) {
+ .lt-narrated-stage,
+ .lt-narrated-stage--in {
+ opacity: 1;
+ transform: none;
+ transition: none;
+ }
+}
+```
+
+Replace it with:
+
+```css
+.lt-narrated-stage {
+ opacity: 0;
+ transform: translateY(10px) scale(0.96);
+}
+.lt-narrated-stage--in {
+ opacity: 1;
+ transform: none;
+ transition: opacity 0.32s cubic-bezier(0.22, 1, 0.36, 1),
+ transform 0.32s cubic-bezier(0.22, 1, 0.36, 1);
+}
+.lt-narrated-spot {
+ animation: lt-narrated-pulse 0.9s ease-out 1;
+}
+@keyframes lt-narrated-pulse {
+ 0% {
+ filter: drop-shadow(0 0 0 rgba(31, 111, 235, 0));
+ transform: scale(1);
+ }
+ 35% {
+ filter: drop-shadow(0 0 6px rgba(31, 111, 235, 0.75));
+ transform: scale(1.06);
+ }
+ 100% {
+ filter: drop-shadow(0 0 0 rgba(31, 111, 235, 0));
+ transform: scale(1);
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .lt-narrated-stage,
+ .lt-narrated-stage--in {
+ opacity: 1;
+ transform: none;
+ transition: none;
+ }
+ .lt-narrated-spot {
+ animation: none;
+ }
+}
+```
+
+- [ ] **Step 3: Regression + syntax**
+
+Run: `node --check app/static/lab-tutor.js && node --test tests/js/test_narrated.js`
+Expected: `node --check` exit 0; all Node tests pass (the helpers/guard untouched).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add app/static/lab-tutor.js app/static/lab-tutor.css
+git commit -m "feat(lab-tutor): spotlight the newly added node + spring step-in
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Task 3: Keyboard control + progress dots
+
+**Files:**
+- Modify: `app/static/lab-tutor.js` — extract shared playback handlers, add dots + card-scoped keyboard.
+- Modify: `app/static/lab-tutor.css` — dot styles.
+
+- [ ] **Step 1: Extract shared handlers and wire dots + keyboard**
+
+In `app/static/lab-tutor.js`, find this EXACT current block (the four `addEventListener` handlers + the final initial-paint line, inside `renderNarratedInto`):
+
+```js
+ playBtn.addEventListener("click", () => {
+ if (state.mode === "playing") {
+ state = narratedReducer(state, { type: "PAUSE" });
+ stopSpeech();
+ syncControls();
+ } else {
+ const wasDone = state.mode === "done";
+ state = narratedReducer(state, { type: wasDone ? "REPLAY" : "PLAY" });
+ run();
+ }
+ });
+ prevBtn.addEventListener("click", () => {
+ stopSpeech();
+ state = narratedReducer(state, { type: "PREV" });
+ if (state.mode === "playing") { state = narratedReducer(state, { type: "PAUSE" }); }
+ renderStep(state.step).then(syncControls);
+ });
+ nextBtn.addEventListener("click", () => {
+ stopSpeech();
+ state = narratedReducer(state, { type: "NEXT" });
+ if (state.mode === "playing") { state = narratedReducer(state, { type: "PAUSE" }); }
+ renderStep(state.step).then(syncControls);
+ });
+ muteBtn.addEventListener("click", () => {
+ muted = !muted;
+ muteBtn.textContent = muted ? "🔇" : "🔊";
+ muteBtn.setAttribute("aria-label", muted ? "Unmute narration" : "Mute narration");
+ if (muted) {
+ stopSpeech();
+ // Muting mid-playback must not rely on cancel() firing onerror to
+ // advance — restart pacing via the no-audio timer explicitly.
+ if (state.mode === "playing") {
+ noAudioTimer = setTimeout(
+ () => { if (state.mode === "playing") advance(); },
+ computeNoAudioMs(spec.steps[state.step].say)
+ );
+ }
+ }
+ });
+
+ // Initial paint: first step visible, idle (no autoplay).
+ renderStep(0).then(syncControls);
+ }
+```
+
+Replace it with:
+
+```js
+ function doPlayPause() {
+ if (state.mode === "playing") {
+ state = narratedReducer(state, { type: "PAUSE" });
+ stopSpeech();
+ syncControls();
+ } else {
+ const wasDone = state.mode === "done";
+ state = narratedReducer(state, { type: wasDone ? "REPLAY" : "PLAY" });
+ run();
+ }
+ }
+ function doPrev() {
+ stopSpeech();
+ state = narratedReducer(state, { type: "PREV" });
+ if (state.mode === "playing") { state = narratedReducer(state, { type: "PAUSE" }); }
+ renderStep(state.step).then(syncControls);
+ }
+ function doNext() {
+ stopSpeech();
+ state = narratedReducer(state, { type: "NEXT" });
+ if (state.mode === "playing") { state = narratedReducer(state, { type: "PAUSE" }); }
+ renderStep(state.step).then(syncControls);
+ }
+
+ playBtn.addEventListener("click", doPlayPause);
+ prevBtn.addEventListener("click", doPrev);
+ nextBtn.addEventListener("click", doNext);
+ muteBtn.addEventListener("click", () => {
+ muted = !muted;
+ muteBtn.textContent = muted ? "🔇" : "🔊";
+ muteBtn.setAttribute("aria-label", muted ? "Unmute narration" : "Mute narration");
+ if (muted) {
+ stopSpeech();
+ // Muting mid-playback must not rely on cancel() firing onerror to
+ // advance — restart pacing via the no-audio timer explicitly.
+ if (state.mode === "playing") {
+ noAudioTimer = setTimeout(
+ () => { if (state.mode === "playing") advance(); },
+ computeNoAudioMs(spec.steps[state.step].say)
+ );
+ }
+ }
+ });
+
+ // Card-scoped keyboard (NEVER document-level — must not leak into
+ // code-server / Monaco / page shortcuts).
+ card.tabIndex = 0;
+ card.setAttribute("role", "group");
+ card.setAttribute("aria-label", "Narrated whiteboard");
+ card.addEventListener("keydown", (e) => {
+ if (e.key === " " || e.key === "Spacebar") {
+ e.preventDefault();
+ doPlayPause();
+ } else if (e.key === "ArrowRight") {
+ e.preventDefault();
+ doNext();
+ } else if (e.key === "ArrowLeft") {
+ e.preventDefault();
+ doPrev();
+ }
+ });
+
+ // Initial paint: first step visible, idle (no autoplay).
+ renderStep(0).then(syncControls);
+ }
+```
+
+- [ ] **Step 2: Add the progress dots element**
+
+In `app/static/lab-tutor.js`, find this EXACT current block (inside `renderNarratedInto`, just after the caption is appended and before the controls are built):
+
+```js
+ const caption = document.createElement("div");
+ caption.className = "lt-narrated-caption";
+ card.appendChild(caption);
+
+ const controls = document.createElement("div");
+ controls.className = "lt-narrated-controls";
+```
+
+Replace it with:
+
+```js
+ const caption = document.createElement("div");
+ caption.className = "lt-narrated-caption";
+ card.appendChild(caption);
+
+ const dots = document.createElement("div");
+ dots.className = "lt-narrated-dots";
+ dots.setAttribute("aria-hidden", "true");
+ const dotEls = [];
+ for (let d = 0; d < spec.steps.length; d++) {
+ const dot = document.createElement("span");
+ dot.className = "lt-narrated-dot";
+ dots.appendChild(dot);
+ dotEls.push(dot);
+ }
+ card.appendChild(dots);
+
+ const controls = document.createElement("div");
+ controls.className = "lt-narrated-controls";
+```
+
+- [ ] **Step 3: Make `syncControls` update the active dot**
+
+In `app/static/lab-tutor.js`, find this EXACT current `syncControls`:
+
+```js
+ function syncControls() {
+ playBtn.textContent =
+ state.mode === "playing" ? "⏸ Pause"
+ : state.mode === "done" ? "↻ Replay"
+ : "▶ Play narration";
+ playBtn.setAttribute(
+ "aria-label",
+ state.mode === "playing" ? "Pause narration"
+ : state.mode === "done" ? "Replay narration"
+ : "Play narration"
+ );
+ prevBtn.disabled = state.step === 0;
+ nextBtn.disabled = state.step >= total - 1 && state.mode !== "playing";
+ }
+```
+
+Replace it with:
+
+```js
+ function syncControls() {
+ playBtn.textContent =
+ state.mode === "playing" ? "⏸ Pause"
+ : state.mode === "done" ? "↻ Replay"
+ : "▶ Play narration";
+ playBtn.setAttribute(
+ "aria-label",
+ state.mode === "playing" ? "Pause narration"
+ : state.mode === "done" ? "Replay narration"
+ : "Play narration"
+ );
+ prevBtn.disabled = state.step === 0;
+ nextBtn.disabled = state.step >= total - 1 && state.mode !== "playing";
+ for (let d = 0; d < dotEls.length; d++) {
+ dotEls[d].classList.toggle("lt-narrated-dot--active", d === state.step);
+ }
+ }
+```
+
+- [ ] **Step 4: Dot styles**
+
+In `app/static/lab-tutor.css`, find this EXACT current block:
+
+```css
+.lt-narrated-caption {
+ margin-top: 8px;
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--lt-text);
+ min-height: 1.5em;
+}
+```
+
+Replace it with:
+
+```css
+.lt-narrated-caption {
+ margin-top: 8px;
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--lt-text);
+ min-height: 1.5em;
+}
+.lt-narrated-dots {
+ display: flex;
+ gap: 6px;
+ margin-top: 8px;
+}
+.lt-narrated-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--lt-border);
+ transition: background 0.15s ease, transform 0.15s ease;
+}
+.lt-narrated-dot--active {
+ background: var(--lt-accent, #1f6feb);
+ transform: scale(1.4);
+}
+@media (prefers-reduced-motion: reduce) {
+ .lt-narrated-dot {
+ transition: none;
+ }
+}
+```
+
+- [ ] **Step 5: Regression + syntax**
+
+Run: `node --check app/static/lab-tutor.js && node --test tests/js/test_narrated.js`
+Expected: `node --check` exit 0; all Node tests pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add app/static/lab-tutor.js app/static/lab-tutor.css
+git commit -m "feat(lab-tutor): card-scoped keyboard control + progress dots
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Task 4: Full regression + manual verification
+
+**Files:** none (verification only)
+
+- [ ] **Step 1: Full automated regression**
+
+Run:
+```bash
+node --check app/static/lab-tutor.js && node --test tests/js/test_narrated.js
+/Users/tushar/Desktop/codebases/course-gen-codex/.venv/bin/python -m pytest tests/test_tutor_service.py tests/test_tutor_routes.py -q
+```
+Expected: `node --check` exit 0; all Node tests pass (19 total); pytest all pass (the persona/routes are untouched — pure regression guard).
+
+- [ ] **Step 2: Manual browser verification (evidence required)**
+
+Local only — no remote deploy. With the local server running (`http://127.0.0.1:8012`), launch a learner studio container, open the tutor, prompt a multi-step process ("walk me through the RAG pipeline step by step"). Record evidence (screenshot/observation) for EACH:
+ - [ ] Each step pulses ONLY the newly added node (not the whole diagram).
+ - [ ] A step that adds no new node (or a non-flowchart diagram) simply doesn't pulse — no error, card still plays.
+ - [ ] Step-in motion reads as a settle (spring), not a hard blink; reduced-motion OS setting disables pulse + transition but dots/keys still work.
+ - [ ] Progress dots track the current step during autoplay and prev/next.
+ - [ ] With the card focused: `Space` toggles play/pause and does NOT scroll the page; `→`/`←` step; keys do NOT leak to the editor when the card is not focused.
+ - [ ] Existing controls (play/pause/prev/next/mute) behave exactly as before; malformed `lt-narrated` still falls back to the raw card.
+
+- [ ] **Step 3: Report**
+
+Do not claim completion until every Step-2 box has recorded evidence (verification-before-completion). Report results. No `git push` / no remote deploy without explicit approval.
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- New-node spotlight (source diff + label-text match, graceful no-op) → Task 1 (`extractNodeLabels`/`diffNewNodeLabels` + tests) + Task 2 (`spotlightNew` DOM apply, try/catch, `g.node` then `[class*="node"]` fallback, 1000ms cleanup).
+- Step 0 → no spotlight → `diffNewNodeLabels` returns `[]` for falsy prev (Task 1 test) and `spotlightNew` early-returns for `i<=0` (Task 2).
+- Motion polish (spring cubic-bezier, more travel) → Task 2 CSS.
+- Reduced-motion neutralizes stage transition AND spotlight → Task 2 `@media` block.
+- Progress dots (decorative, `aria-hidden`, between caption and controls, active tracks `state.step`) → Task 3 Steps 2–4.
+- Keyboard card-scoped (`tabindex`/`role`/`aria-label`, listener on card not document, Space preventDefault, arrows) reusing existing handler logic → Task 3 Step 1.
+- Pure helpers Node-unit-tested; DOM/CSS/keyboard manual → Tasks 1 & 4.
+- Non-goals respected (no voice/LLM/persistence/server/new-file; no tap-to-advance; no autoplay change) — no such tasks; Task 4 forbids push.
+
+**Placeholder scan:** No TBD/TODO; every code step has complete code; commands have expected output.
+
+**Type consistency:** `extractNodeLabels(src)→string[]`, `diffNewNodeLabels(prevSrc,currSrc)→string[]` consistent across Task 1 tests, the `module.exports` list, and Task 2's `spotlightNew`. `spotlightNew(i)` called only from `renderStep` after render. Shared handlers `doPlayPause/doPrev/doNext` defined once (Task 3 Step 1) and referenced by both click and keydown. `dotEls` created in Task 3 Step 2 and consumed in Task 3 Step 3's `syncControls`. CSS classes (`lt-narrated-spot`, `lt-narrated-dots`, `lt-narrated-dot`, `lt-narrated-dot--active`) match between JS and CSS tasks. `--lt-accent` referenced with a `#1f6feb` fallback in case the variable name differs.
diff --git a/docs/superpowers/plans/2026-05-16-narrated-whiteboard.md b/docs/superpowers/plans/2026-05-16-narrated-whiteboard.md
new file mode 100644
index 0000000..7ade78c
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-16-narrated-whiteboard.md
@@ -0,0 +1,660 @@
+# Narrated Whiteboard Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let the Lab Tutor explain a multi-step concept as a step-by-step Mermaid reveal narrated by browser text-to-speech, with no extra LLM call and zero TTS cost.
+
+**Architecture:** The tutor emits a fenced ```` ```lt-narrated ```` block (JSON with a `steps[]` array; each step has its own cumulative Mermaid source + a `say` line). The widget parses it and renders a playback card that, per step, re-renders the step's Mermaid and speaks `say` via `window.speechSynthesis`, advancing on the utterance `onend` event (event-based cursor, no timeline). Pure logic (parser, no-audio timer, playback reducer) is factored into testable functions guarded for Node so they can be unit-tested with Node's built-in test runner; DOM/playback wiring stays in `lab-tutor.js`.
+
+**Tech Stack:** Vanilla ES2020 (no bundler), existing vendored `mermaid.min.js`, browser `SpeechSynthesis`, Python 3 / pytest + `unittest` for the tutor-prompt contract, Node v24 built-in test runner (`node --test`, zero new deps) for JS pure logic.
+
+---
+
+## File Structure
+
+- `app/static/lab-tutor.js` (modify) — add three pure helpers + a Node-export guard near the top of the IIFE; add `renderNarratedInto` + `lt-narrated` fence handling in `appendTutor`.
+- `app/static/lab-tutor.css` (modify) — add `.lt-narrated*` styles incl. per-step entry transition.
+- `app/services/tutor_service.py` (modify) — extend `_TUTOR_PERSONA` with `lt-narrated` guidance + single-pass constraint.
+- `tests/js/test_narrated.js` (create) — Node `node:test` unit tests for the three pure helpers.
+- `tests/test_tutor_service.py` (modify) — add a test asserting the persona contract.
+
+**V1 simplification (deliberate, flagged at handoff):** per-card playback position is NOT persisted. On reload the narrated card re-renders fresh at `idle` (ready to replay) — identical to how the existing Mermaid card already behaves. The spec's "persist last-played step" is deferred; adding it would require extending the localStorage history schema (`loadHistory` only retains `{role,text}`), which is disproportionate for V1.
+
+---
+
+## Task 1: Pure helpers + Node export guard
+
+**Files:**
+- Modify: `app/static/lab-tutor.js` (insert after line 11 `"use strict";`, before line 14 `const me = document.currentScript`)
+- Test: `tests/js/test_narrated.js`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/js/test_narrated.js`:
+
+```js
+"use strict";
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const path = require("node:path");
+
+// Requiring lab-tutor.js under Node hits the export guard and returns the
+// pure helpers without touching the DOM.
+const lt = require(path.join(__dirname, "..", "..", "app", "static", "lab-tutor.js"));
+
+test("parseNarrated: valid JSON returns normalized steps", () => {
+ const body = JSON.stringify({
+ steps: [
+ { say: "Chunk the docs.", mermaid: "flowchart LR\n A-->B" },
+ { say: "Embed them.", mermaid: "flowchart LR\n A-->B-->C" },
+ ],
+ });
+ const out = lt.parseNarrated(body);
+ assert.equal(out.steps.length, 2);
+ assert.equal(out.steps[0].say, "Chunk the docs.");
+ assert.equal(out.steps[1].mermaid, "flowchart LR\n A-->B-->C");
+});
+
+test("parseNarrated: repairs trailing commas", () => {
+ const body = '{ "steps": [ { "say": "x", "mermaid": "graph TD\\n A", }, ], }';
+ const out = lt.parseNarrated(body);
+ assert.ok(out);
+ assert.equal(out.steps.length, 1);
+ assert.equal(out.steps[0].say, "x");
+});
+
+test("parseNarrated: strips stray prose around the object", () => {
+ const body = 'Sure! Here it is:\n{ "steps": [ { "say": "a", "mermaid": "graph TD\\n A" } ] }\nHope that helps.';
+ const out = lt.parseNarrated(body);
+ assert.ok(out);
+ assert.equal(out.steps[0].say, "a");
+});
+
+test("parseNarrated: garbage or empty -> null", () => {
+ assert.equal(lt.parseNarrated("not json at all <<<"), null);
+ assert.equal(lt.parseNarrated('{"steps": []}'), null);
+ assert.equal(lt.parseNarrated('{"steps": [{"say": 1}]}'), null);
+});
+
+test("computeNoAudioMs: max(2000, words*240)", () => {
+ assert.equal(lt.computeNoAudioMs("one two three"), 2000); // 3*240=720 -> floor 2000
+ assert.equal(lt.computeNoAudioMs("a ".repeat(20).trim()), 20 * 240);
+ assert.equal(lt.computeNoAudioMs(""), 2000);
+});
+
+test("narratedReducer: play/advance/done flow", () => {
+ let s = { mode: "idle", step: 0, total: 3 };
+ s = lt.narratedReducer(s, { type: "PLAY" });
+ assert.deepEqual(s, { mode: "playing", step: 0, total: 3 });
+ s = lt.narratedReducer(s, { type: "ADVANCE" });
+ assert.deepEqual(s, { mode: "playing", step: 1, total: 3 });
+ s = lt.narratedReducer(s, { type: "ADVANCE" });
+ s = lt.narratedReducer(s, { type: "ADVANCE" }); // step 2 -> done
+ assert.equal(s.mode, "done");
+ assert.equal(s.step, 2);
+});
+
+test("narratedReducer: stale ADVANCE ignored when not playing", () => {
+ let s = { mode: "paused", step: 1, total: 3 };
+ const after = lt.narratedReducer(s, { type: "ADVANCE" });
+ assert.deepEqual(after, s); // unchanged — stale-callback guard
+});
+
+test("narratedReducer: PAUSE/NEXT/PREV/REPLAY", () => {
+ let s = { mode: "playing", step: 0, total: 3 };
+ s = lt.narratedReducer(s, { type: "PAUSE" });
+ assert.equal(s.mode, "paused");
+ s = lt.narratedReducer(s, { type: "NEXT" });
+ assert.deepEqual(s, { mode: "paused", step: 1, total: 3 });
+ s = lt.narratedReducer(s, { type: "PREV" });
+ assert.deepEqual(s, { mode: "paused", step: 0, total: 3 });
+ s = lt.narratedReducer(s, { type: "PREV" }); // clamp at 0
+ assert.equal(s.step, 0);
+ s = lt.narratedReducer({ mode: "done", step: 2, total: 3 }, { type: "REPLAY" });
+ assert.deepEqual(s, { mode: "playing", step: 0, total: 3 });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `node --test tests/js/test_narrated.js`
+Expected: FAIL — `lt.parseNarrated is not a function` (the export guard / helpers don't exist yet).
+
+- [ ] **Step 3: Write minimal implementation**
+
+In `app/static/lab-tutor.js`, immediately after line 11 (` "use strict";`) and before line 13's config comment, insert:
+
+```js
+
+ // ── Narrated-whiteboard pure helpers (also unit-tested under Node) ─────────
+ // Parse an lt-narrated block body into { steps: [{say, mermaid}] } or null.
+ function parseNarrated(body) {
+ function tryParse(s) {
+ try { return JSON.parse(s); } catch { return undefined; }
+ }
+ let obj = tryParse(body);
+ if (obj === undefined) {
+ let repaired = String(body);
+ const open = repaired.indexOf("{");
+ const close = repaired.lastIndexOf("}");
+ if (open !== -1 && close > open) repaired = repaired.slice(open, close + 1);
+ repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); // trailing commas
+ obj = tryParse(repaired);
+ }
+ if (!obj || !Array.isArray(obj.steps)) return null;
+ const steps = obj.steps.filter(
+ (st) =>
+ st &&
+ typeof st.say === "string" &&
+ st.say.trim() !== "" &&
+ typeof st.mermaid === "string" &&
+ st.mermaid.trim() !== ""
+ ).map((st) => ({ say: st.say, mermaid: st.mermaid }));
+ return steps.length > 0 ? { steps } : null;
+ }
+
+ // No-audio / muted pacing: max(2s, words*240ms) (pattern from OpenMAIC).
+ function computeNoAudioMs(say) {
+ const words = String(say).trim().split(/\s+/).filter(Boolean).length;
+ return Math.max(2000, words * 240);
+ }
+
+ // Pure playback state machine. State: {mode,step,total}.
+ // mode: "idle" | "playing" | "paused" | "done".
+ function narratedReducer(state, action) {
+ const s = state;
+ switch (action.type) {
+ case "PLAY":
+ if (s.mode === "done") return { mode: "playing", step: 0, total: s.total };
+ return { mode: "playing", step: s.step, total: s.total };
+ case "PAUSE":
+ if (s.mode !== "playing") return s;
+ return { mode: "paused", step: s.step, total: s.total };
+ case "ADVANCE": {
+ // Stale-callback guard: only the active "playing" mode advances.
+ if (s.mode !== "playing") return s;
+ const next = s.step + 1;
+ if (next >= s.total) return { mode: "done", step: s.total - 1, total: s.total };
+ return { mode: "playing", step: next, total: s.total };
+ }
+ case "NEXT": {
+ const next = Math.min(s.step + 1, s.total - 1);
+ const done = s.step + 1 >= s.total;
+ return { mode: done ? "done" : s.mode, step: next, total: s.total };
+ }
+ case "PREV":
+ return { mode: s.mode, step: Math.max(s.step - 1, 0), total: s.total };
+ case "REPLAY":
+ return { mode: "playing", step: 0, total: s.total };
+ case "STOP":
+ return { mode: "idle", step: 0, total: s.total };
+ default:
+ return s;
+ }
+ }
+
+ // Node-only export hook for unit tests. In the browser `module` is
+ // undefined so this is skipped and the widget bootstraps normally.
+ if (typeof module !== "undefined" && module.exports) {
+ module.exports = { parseNarrated, computeNoAudioMs, narratedReducer };
+ return;
+ }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `node --test tests/js/test_narrated.js`
+Expected: PASS — all 8 tests pass.
+
+- [ ] **Step 5: Verify the browser path is not broken**
+
+Run: `node -e "global.window={};global.document={currentScript:null,querySelector:()=>null};try{require('./app/static/lab-tutor.js');console.log('browser-path require did not early-return (expected, guard only triggers under module.exports)')}catch(e){console.log('threw:',e.message)}"`
+Expected: it threw (because real DOM is absent) OR printed the no-early-return line — either is fine. This step only confirms the guard is `module.exports`-gated, not that the widget runs headless. The authoritative browser check is Task 6.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add app/static/lab-tutor.js tests/js/test_narrated.js
+git commit -m "feat(lab-tutor): narrated-whiteboard pure helpers + Node unit tests
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Task 2: Renderer + playback wiring in the widget
+
+**Files:**
+- Modify: `app/static/lab-tutor.js` — add `renderNarratedInto` near the Mermaid helpers (after `renderMermaidInto`, which ends ~line 137); add an `lt-narrated` scan in `appendTutor` (around lines 379-398).
+
+No automated test (no DOM test harness in this repo by design). Logic correctness is covered by Task 1's pure-function tests; integration is verified manually in Task 6.
+
+- [ ] **Step 1: Add the narrated renderer**
+
+In `app/static/lab-tutor.js`, immediately AFTER the `renderMermaidInto` function (just before the `// ──` comment that follows it, ~line 137), insert:
+
+```js
+
+ // Render a narrated-whiteboard card: stepwise Mermaid reveal + TTS.
+ let narratedIdCounter = 0;
+ function renderNarratedInto(parent, spec) {
+ const card = document.createElement("div");
+ card.className = "lt-narrated";
+
+ const stage = document.createElement("div");
+ stage.className = "lt-narrated-stage";
+ card.appendChild(stage);
+
+ const caption = document.createElement("div");
+ caption.className = "lt-narrated-caption";
+ card.appendChild(caption);
+
+ const controls = document.createElement("div");
+ controls.className = "lt-narrated-controls";
+ const mkBtn = (label, aria) => {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "lt-narrated-btn";
+ b.textContent = label;
+ b.setAttribute("aria-label", aria);
+ return b;
+ };
+ const playBtn = mkBtn("▶ Play narration", "Play narration");
+ const prevBtn = mkBtn("‹", "Previous step");
+ const nextBtn = mkBtn("›", "Next step");
+ const muteBtn = mkBtn("🔊", "Mute narration");
+ controls.append(playBtn, prevBtn, nextBtn, muteBtn);
+ card.appendChild(controls);
+ parent.appendChild(card);
+
+ const total = spec.steps.length;
+ let state = { mode: "idle", step: 0, total };
+ let muted = false;
+ let noAudioTimer = null;
+ const synth = window.speechSynthesis || null;
+ const ttsOk = !!(synth && window.SpeechSynthesisUtterance);
+ if (!ttsOk) {
+ muteBtn.style.display = "none";
+ muted = true;
+ }
+
+ function clearTimer() {
+ if (noAudioTimer) { clearTimeout(noAudioTimer); noAudioTimer = null; }
+ }
+ function stopSpeech() {
+ clearTimer();
+ if (ttsOk) { try { synth.cancel(); } catch {} }
+ }
+
+ async function renderStep(i) {
+ const step = spec.steps[i];
+ caption.textContent = step.say;
+ stage.classList.remove("lt-narrated-stage--in");
+ stage.innerHTML = "";
+ // reflow so the entry transition re-triggers
+ void stage.offsetWidth;
+ await renderMermaidInto(stage, step.mermaid);
+ stage.classList.add("lt-narrated-stage--in");
+ }
+
+ function syncControls() {
+ playBtn.textContent =
+ state.mode === "playing" ? "⏸ Pause"
+ : state.mode === "done" ? "↻ Replay"
+ : "▶ Play narration";
+ playBtn.setAttribute(
+ "aria-label",
+ state.mode === "playing" ? "Pause narration"
+ : state.mode === "done" ? "Replay narration"
+ : "Play narration"
+ );
+ prevBtn.disabled = state.step === 0;
+ nextBtn.disabled = state.step >= total - 1 && state.mode !== "playing";
+ }
+
+ function speakCurrent() {
+ const step = spec.steps[state.step];
+ stopSpeech();
+ const advance = () => {
+ const tokenMode = state.mode;
+ state = narratedReducer(state, { type: "ADVANCE" });
+ if (state.mode === "playing" && tokenMode === "playing") {
+ run();
+ } else {
+ syncControls();
+ }
+ };
+ if (!muted && ttsOk) {
+ const u = new SpeechSynthesisUtterance(step.say);
+ u.onend = () => { if (state.mode === "playing") advance(); };
+ u.onerror = () => { if (state.mode === "playing") advance(); };
+ try { synth.speak(u); }
+ catch { noAudioTimer = setTimeout(() => { if (state.mode === "playing") advance(); }, computeNoAudioMs(step.say)); }
+ } else {
+ noAudioTimer = setTimeout(
+ () => { if (state.mode === "playing") advance(); },
+ computeNoAudioMs(step.say)
+ );
+ }
+ }
+
+ async function run() {
+ await renderStep(state.step);
+ syncControls();
+ if (state.mode === "playing") speakCurrent();
+ }
+
+ playBtn.addEventListener("click", () => {
+ if (state.mode === "playing") {
+ state = narratedReducer(state, { type: "PAUSE" });
+ stopSpeech();
+ syncControls();
+ } else {
+ const wasDone = state.mode === "done";
+ state = narratedReducer(state, { type: wasDone ? "REPLAY" : "PLAY" });
+ run();
+ }
+ });
+ prevBtn.addEventListener("click", () => {
+ stopSpeech();
+ state = narratedReducer(state, { type: "PREV" });
+ if (state.mode === "playing") { state = narratedReducer(state, { type: "PAUSE" }); }
+ renderStep(state.step).then(syncControls);
+ });
+ nextBtn.addEventListener("click", () => {
+ stopSpeech();
+ state = narratedReducer(state, { type: "NEXT" });
+ if (state.mode === "playing") { state = narratedReducer(state, { type: "PAUSE" }); }
+ renderStep(state.step).then(syncControls);
+ });
+ muteBtn.addEventListener("click", () => {
+ muted = !muted;
+ muteBtn.textContent = muted ? "🔇" : "🔊";
+ muteBtn.setAttribute("aria-label", muted ? "Unmute narration" : "Mute narration");
+ if (muted) stopSpeech();
+ });
+
+ // Initial paint: first step visible, idle (no autoplay).
+ renderStep(0).then(syncControls);
+ }
+```
+
+- [ ] **Step 2: Wire the `lt-narrated` fence into `appendTutor`**
+
+In `appendTutor` (the mermaid-fence block ~lines 379-398), replace this exact block:
+
+```js
+ // Split on mermaid fences. Even indices = text, odd indices = mermaid code.
+ const fence = /```mermaid\n([\s\S]*?)\n```/g;
+ let lastIndex = 0;
+ let m;
+ let any = false;
+ while ((m = fence.exec(text)) !== null) {
+ any = true;
+ const before = text.slice(lastIndex, m.index);
+ if (before.trim()) appendParagraphsBold(wrap, before);
+ const host = document.createElement("div");
+ host.className = "lt-mermaid-host";
+ wrap.appendChild(host);
+ // Fire-and-forget; the placeholder appears synchronously.
+ renderMermaidInto(host, m[1]);
+ lastIndex = m.index + m[0].length;
+ }
+```
+
+with:
+
+```js
+ // Split on lt-narrated AND mermaid fences. A combined scanner keeps
+ // text/diagram/narrated ordering intact.
+ const fence = /```(lt-narrated|mermaid)\n([\s\S]*?)\n```/g;
+ let lastIndex = 0;
+ let m;
+ let any = false;
+ while ((m = fence.exec(text)) !== null) {
+ any = true;
+ const before = text.slice(lastIndex, m.index);
+ if (before.trim()) appendParagraphsBold(wrap, before);
+ const host = document.createElement("div");
+ wrap.appendChild(host);
+ if (m[1] === "lt-narrated") {
+ const spec = parseNarrated(m[2]);
+ if (spec) {
+ host.className = "lt-narrated-host";
+ renderNarratedInto(host, spec);
+ } else {
+ // Fallback: treat the raw body as a failed-mermaid-style card.
+ host.className = "lt-mermaid lt-mermaid--failed";
+ const pre = document.createElement("pre");
+ pre.textContent = m[2];
+ host.appendChild(pre);
+ }
+ } else {
+ host.className = "lt-mermaid-host";
+ renderMermaidInto(host, m[2]); // fire-and-forget
+ }
+ lastIndex = m.index + m[0].length;
+ }
+```
+
+- [ ] **Step 3: Re-run the pure-helper tests (regression guard)**
+
+Run: `node --test tests/js/test_narrated.js`
+Expected: PASS — 8/8. (Confirms the edits above didn't disturb the guarded helpers / early return.)
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add app/static/lab-tutor.js
+git commit -m "feat(lab-tutor): narrated-whiteboard renderer + playback wiring
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Task 3: Styles
+
+**Files:**
+- Modify: `app/static/lab-tutor.css` (append a new section at end of file, after the `.lt-mermaid` rules ending ~line 395)
+
+- [ ] **Step 1: Append the narrated-whiteboard styles**
+
+Append to `app/static/lab-tutor.css`:
+
+```css
+
+/* Narrated whiteboard */
+.lt-narrated {
+ margin: 8px 0;
+ padding: 12px;
+ background: #fafafa;
+ border: 1px solid #e5e7eb;
+ border-radius: 8px;
+}
+.lt-narrated-stage {
+ opacity: 0;
+ transform: translateY(6px) scale(0.98);
+}
+.lt-narrated-stage--in {
+ opacity: 1;
+ transform: none;
+ transition: opacity 0.22s ease, transform 0.22s ease;
+}
+@media (prefers-reduced-motion: reduce) {
+ .lt-narrated-stage,
+ .lt-narrated-stage--in {
+ opacity: 1;
+ transform: none;
+ transition: none;
+ }
+}
+.lt-narrated-stage svg {
+ max-width: 100%;
+ height: auto;
+ display: block;
+ margin: 0 auto;
+}
+.lt-narrated-caption {
+ margin-top: 8px;
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--lt-text);
+ min-height: 1.5em;
+}
+.lt-narrated-controls {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 10px;
+}
+.lt-narrated-btn {
+ padding: 4px 10px;
+ border: 1px solid var(--lt-border);
+ background: var(--lt-surface);
+ border-radius: 6px;
+ font-size: 12px;
+ font-family: var(--lt-font);
+ color: var(--lt-text);
+ cursor: pointer;
+ transition: background 0.1s ease;
+}
+.lt-narrated-btn:hover:not(:disabled) {
+ background: var(--lt-surface-2);
+}
+.lt-narrated-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+.lt-narrated-controls .lt-narrated-btn:first-child {
+ margin-right: auto;
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add app/static/lab-tutor.css
+git commit -m "feat(lab-tutor): narrated-whiteboard styles + reduced-motion fallback
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Task 4: Tutor prompt guidance (TDD)
+
+**Files:**
+- Modify: `app/services/tutor_service.py` — extend `_TUTOR_PERSONA` (string ends at line 44)
+- Test: `tests/test_tutor_service.py` (append a new test method/class)
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `tests/test_tutor_service.py`:
+
+```python
+class TutorPersonaNarratedContract(unittest.TestCase):
+ def test_persona_documents_lt_narrated_and_single_pass(self) -> None:
+ from app.services.tutor_service import _TUTOR_PERSONA
+
+ self.assertIn("```lt-narrated", _TUTOR_PERSONA)
+ # JSON shape the widget parses
+ self.assertIn('"steps"', _TUTOR_PERSONA)
+ self.assertIn('"say"', _TUTOR_PERSONA)
+ self.assertIn('"mermaid"', _TUTOR_PERSONA)
+ # single-pass constraint must be stated
+ self.assertIn("same reply", _TUTOR_PERSONA.lower())
+ # plain mermaid path must still be documented
+ self.assertIn("```mermaid", _TUTOR_PERSONA)
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python -m pytest tests/test_tutor_service.py::TutorPersonaNarratedContract -v`
+Expected: FAIL — `AssertionError` (no `` ```lt-narrated `` in the persona yet).
+
+- [ ] **Step 3: Add the guidance to the persona**
+
+In `app/services/tutor_service.py`, replace the final bullet of `_TUTOR_PERSONA` (line 44):
+
+```
+- Don't draw a diagram for every reply. Skip it when the answer is a one-line concept, a syntax lookup, or a quick yes/no. The bar: would a real tutor reach for the whiteboard here? If not, stay in prose."""
+```
+
+with:
+
+```
+- Don't draw a diagram for every reply. Skip it when the answer is a one-line concept, a syntax lookup, or a quick yes/no. The bar: would a real tutor reach for the whiteboard here? If not, stay in prose.
+- When the explanation is a multi-step PROCESS (a pipeline, a state machine, a data flow, an algorithm walkthrough) and building it up stage by stage would help more than one static picture, use a narrated whiteboard instead of a plain diagram. Emit a fenced block tagged lt-narrated whose body is JSON with a "steps" array; each step has a one-sentence "say" line and its own CUMULATIVE "mermaid" source (step N draws nodes 1..N). 3-6 steps. Produce the whole JSON in the SAME reply (one pass — never promise to send it next):
+ ```lt-narrated
+ {"steps":[
+ {"say":"First the query is embedded.","mermaid":"flowchart LR\\n Q[Query]-->E[Embed]"},
+ {"say":"Then we search the vector store.","mermaid":"flowchart LR\\n Q[Query]-->E[Embed]-->S[Search]"}
+ ]}
+ ```
+ Use the plain ```mermaid path for a single static diagram; use lt-narrated only when the step-by-step build is the teaching point. Still pair it with one short follow-up question or hint."""
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python -m pytest tests/test_tutor_service.py::TutorPersonaNarratedContract -v`
+Expected: PASS.
+
+- [ ] **Step 5: Run the full tutor-service suite (regression)**
+
+Run: `python -m pytest tests/test_tutor_service.py -v`
+Expected: PASS — all pre-existing tests still green (the line-98 "plain persona" test must not regress; the new bullet is appended, persona-only behavior unchanged).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add app/services/tutor_service.py tests/test_tutor_service.py
+git commit -m "feat(lab-tutor): persona guidance for lt-narrated (single-pass)
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Task 5: Full regression + manual browser verification
+
+**Files:** none (verification only)
+
+- [ ] **Step 1: Full JS + Python regression**
+
+Run: `node --test tests/js/ && python -m pytest tests/test_tutor_service.py tests/test_tutor_routes.py -q`
+Expected: all PASS.
+
+- [ ] **Step 2: Manual browser verification (evidence required)**
+
+Local only — no remote deploy. Start the app locally with `LAB_TUTOR_BASE_URL=http://127.0.0.1:8012`, launch a learner studio container, open the editor, open the tutor, and prompt something that triggers a multi-step process explanation (e.g. "walk me through the RAG pipeline step by step"). Confirm and record evidence (screenshot or written observation) for EACH:
+ - [ ] A narrated card renders with a "▶ Play narration" button (no autoplay).
+ - [ ] Play reveals step 1's diagram + caption, speaks it, then auto-advances to step 2 on speech end (event-based sync).
+ - [ ] Pause stops speech and the cursor does not jump (stale-callback guard holds).
+ - [ ] Prev/Next move steps and disable correctly at the ends.
+ - [ ] Mute → playback continues visually on the no-audio timer (~2s+/step).
+ - [ ] Reload the page: the card re-renders fresh at idle (documented V1 behavior — position not persisted), chat history otherwise intact.
+ - [ ] A deliberately malformed `lt-narrated` block falls back to a raw card and does NOT break the message log.
+
+- [ ] **Step 3: Update the running-feature todo and report**
+
+Do NOT claim completion until every Step-2 box has recorded evidence (verification-before-completion). Report results to the user. No `git push` / no remote deploy without explicit approval.
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- Block format (`lt-narrated`, JSON, cumulative mermaid) → Task 1 (`parseNarrated`) + Task 4 (persona emits it).
+- Widget renderer + Play button (no autoplay) + controls + caption fallback → Task 2 + Task 3.
+- Event-based sync, no-audio timer `max(2s, words*240)`, stale-callback guard → Task 1 (reducer/timer) + Task 2 (wiring).
+- Per-step CSS entry transition + reduced-motion → Task 3.
+- Parser robustness ladder → Task 1.
+- Tutor prompt single-pass guidance → Task 4.
+- Verification (Python contract + JS units + manual) → Tasks 1, 4, 5.
+- Non-goals (no games/video/server-TTS/OpenMAIC/course-gen/deploy) → respected; no such tasks; Task 5 Step 3 forbids push.
+- Spec "persist last-played step" → consciously deferred; documented under File Structure as a V1 simplification and surfaced in Task 5 Step 2 / handoff.
+
+**Placeholder scan:** No TBD/TODO; every code step contains complete code; commands have expected output.
+
+**Type consistency:** `parseNarrated` returns `{steps:[{say,mermaid}]}` — consumed identically in Task 2. `narratedReducer(state,action)` state shape `{mode,step,total}` and action types `PLAY/PAUSE/ADVANCE/NEXT/PREV/REPLAY/STOP` consistent across Task 1 tests and Task 2 wiring. `computeNoAudioMs(say)` signature consistent. CSS class names (`lt-narrated`, `lt-narrated-stage`, `lt-narrated-stage--in`, `lt-narrated-caption`, `lt-narrated-controls`, `lt-narrated-btn`, `lt-narrated-host`) match between Task 2 and Task 3.
diff --git a/docs/superpowers/specs/2026-05-16-narrated-whiteboard-attention-polish-design.md b/docs/superpowers/specs/2026-05-16-narrated-whiteboard-attention-polish-design.md
new file mode 100644
index 0000000..a3b42af
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-16-narrated-whiteboard-attention-polish-design.md
@@ -0,0 +1,154 @@
+# Narrated Whiteboard — Attention Polish Design
+
+Date: 2026-05-16
+Branch: `claude/agitated-hamilton-f1b773`
+Status: Approved (design), pre-implementation
+Builds on: `2026-05-16-narrated-whiteboard-design.md` (the shipped narrated-whiteboard feature)
+
+## Goal
+
+Increase engagement of the existing narrated-whiteboard card by making each
+step visually arresting and learner-controllable — without touching voice
+quality, adding LLM calls, persistence, or new infra. Scope is deliberately
+"Bundle A: attention polish only": **new-node spotlight + motion polish +
+keyboard/progress-dots**.
+
+## Non-Goals (explicit)
+
+- Caption word-sync, predict-the-next gate, recap takeaway, code-anchored
+ narrative (these were Bundles B/C — out of scope here).
+- Tap-to-advance; any change to the no-autoplay behavior.
+- Any new LLM call, persistence-schema change, server change, or new file
+ injection site.
+- Voice/TTS changes of any kind.
+- Remote deploy. Local build + verify only; no push without explicit approval.
+
+## Context
+
+The shipped feature renders an `lt-narrated` card via `renderNarratedInto`
+in `app/static/lab-tutor.js`. Per step it sets a caption and re-renders the
+step's **cumulative** Mermaid source (`renderMermaidInto`), then plays the
+entry transition via the `.lt-narrated-stage--in` class. Playback is the
+pure `narratedReducer` state machine; pure helpers (`parseNarrated`,
+`computeNoAudioMs`, `narratedReducer`) are unit-tested under Node
+(`tests/js/test_narrated.js`) via a module-export guard. There is no JS DOM
+test harness in the repo by design.
+
+## Architecture
+
+Three independent, additive sub-features. Each degrades gracefully and is
+off the critical path (if any fails, the card still plays exactly as today).
+
+### 1. New-node spotlight
+
+Per the original design, each step carries its OWN cumulative Mermaid
+source. To emphasize only what changed, diff the SOURCE (not the rendered
+SVG — SVG-id targeting is brittle across Mermaid versions and was
+deliberately avoided in the base feature).
+
+**Pure helpers (Node-unit-tested, added to the existing guarded block):**
+
+- `extractNodeLabels(mermaidSrc)` → ordered array of distinct human-visible
+ node label strings. Parses flowchart/graph node declarations:
+ - `A[Label]`, `A(Label)`, `A((Label))`, `A{Label}`, `A>Label]`,
+ `A([Label])`, `A[[Label]]` — capture the bracketed label.
+ - Bare node ids that appear in edges without a label declaration
+ (`A --> B`) contribute the id as its own label only if the id never
+ receives a bracketed label anywhere in the source.
+ - Ignores the diagram-type header line (`flowchart LR`, `graph TD`,
+ `stateDiagram-v2`, etc.) and edge-label text (`A -->|text| B`).
+ - Deterministic, no DOM, no regex catastrophic backtracking (bounded
+ quantifiers).
+- `diffNewNodeLabels(prevMermaidSrc, currMermaidSrc)` → labels present in
+ `curr` but not in `prev` (set difference on `extractNodeLabels` output,
+ order preserved by `curr`). For step 0 (`prev` undefined/empty) → `[]`
+ (no spotlight on the first paint; nothing is "new").
+
+**DOM application (in `renderNarratedInto`, best-effort, non-fatal):**
+
+After `renderMermaidInto(stage, step.mermaid)` resolves for step i where
+i > 0:
+1. Compute `newLabels = diffNewNodeLabels(spec.steps[i-1].mermaid,
+ spec.steps[i].mermaid)`.
+2. For each new label, find the FIRST SVG node element in `stage` whose
+ trimmed text content equals the label, scoped to Mermaid node containers
+ (`g.node`, falling back to any `.nodeLabel`/`text` whose trimmed text
+ matches). Add class `lt-narrated-spot`.
+3. Remove `lt-narrated-spot` after 1000ms (slightly longer than the CSS
+ animation) so re-render of the next step starts clean.
+4. Any exception or zero matches → swallow and continue (spotlight is a
+ nicety, never a failure path). Matching is by the label the learner
+ actually sees, so it tracks Mermaid markup changes.
+
+### 2. Motion polish
+
+Replace the `.lt-narrated-stage--in` transition timing function with a
+spring-ish `cubic-bezier(0.22, 1, 0.36, 1)` and increase initial travel
+slightly (`translateY(10px) scale(0.96)` → settle). Pure CSS. The existing
+`@media (prefers-reduced-motion: reduce)` block already neutralizes the
+stage transform/opacity/transition and must also neutralize the new
+spotlight animation.
+
+### 3. Keyboard + progress dots
+
+- **Progress dots:** add a `.lt-narrated-dots` container (built once from
+ `spec.steps.length`) of `.lt-narrated-dot` spans. `syncControls` toggles
+ `.lt-narrated-dot--active` on the dot at `state.step`. Decorative:
+ `aria-hidden="true"` (the buttons already carry the accessible state).
+ Placed between the caption and the controls row.
+- **Keyboard:** the card root gets `tabindex="0"`, `role="group"`, and
+ `aria-label="Narrated whiteboard"`. A `keydown` listener is attached to
+ the CARD ELEMENT (never `document`) so it cannot interfere with
+ code-server / Monaco / page shortcuts:
+ - `Space` → same as clicking play/pause; `event.preventDefault()` to
+ stop the page scrolling.
+ - `ArrowRight` → same as Next button; `ArrowLeft` → same as Prev button.
+ - Reuse the EXISTING button click handlers' logic (extract the
+ play/pause, next, prev bodies into named functions the listeners and
+ keys share — no behavior change, just shared call sites).
+ - Any other key → ignored (no preventDefault).
+
+## Data Flow
+
+`spec.steps[i].mermaid` (already in memory) → `diffNewNodeLabels` → label
+list → DOM query within the freshly rendered `stage` → transient CSS class.
+No new data, no network, no storage. Dots derive purely from
+`spec.steps.length` and `state.step`. Keyboard maps to existing playback
+actions through `narratedReducer` (unchanged).
+
+## Error Handling
+
+- `extractNodeLabels`/`diffNewNodeLabels` are total functions: malformed or
+ exotic Mermaid → return whatever labels parse (possibly `[]`); never
+ throw. Unit tests cover empty input, no-new-node steps, label-shape
+ variants, and a non-flowchart header.
+- Spotlight DOM step is wrapped so any error (no SVG, no match, unexpected
+ markup) is swallowed; the card continues normally.
+- Keyboard handler only acts on the three keys; everything else passes
+ through. Listener is card-scoped and removed implicitly when the message
+ log is cleared (same lifecycle as today's listeners; no new global
+ state).
+- Reduced-motion users: no spotlight animation, no stage transition (CSS
+ media query), but dots + keyboard still work.
+
+## Testing / Verification
+
+- **Node unit tests** (extend `tests/js/test_narrated.js`):
+ `extractNodeLabels` (bracket shapes, bare-id edges, header/edge-label
+ exclusion, dedupe/order) and `diffNewNodeLabels` (step 0 → [], added
+ node, no-change step, label rename treated as add). Run:
+ `node --test tests/js/test_narrated.js`.
+- **node --check** on `lab-tutor.js`; full Node suite green.
+- **Manual browser verification** (consistent with base feature; no DOM
+ harness): trigger a narrated reply, confirm — only the newly added node
+ pulses each step; motion feels like a settle not a blink; dots track the
+ step; `Space`/`←`/`→` work when the card is focused and do NOT scroll the
+ page or leak to the editor; reduced-motion disables animations but keeps
+ dots/keys; a non-flowchart or odd diagram simply doesn't spotlight (no
+ breakage). Evidence required before any "it works" claim.
+
+## Rollout
+
+Additive and local-only. If `diffNewNodeLabels` returns `[]` or matching
+fails, behavior is identical to the shipped feature. No remote deploy, no
+push without explicit approval.
diff --git a/docs/superpowers/specs/2026-05-16-narrated-whiteboard-design.md b/docs/superpowers/specs/2026-05-16-narrated-whiteboard-design.md
new file mode 100644
index 0000000..57de14e
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-16-narrated-whiteboard-design.md
@@ -0,0 +1,191 @@
+# Narrated Whiteboard — Tutor Enhancement Design
+
+Date: 2026-05-16
+Branch: `claude/agitated-hamilton-f1b773`
+Status: Approved (design), pre-implementation
+
+## Goal
+
+Make the Lab Tutor's conceptual explanations more engaging by delivering them
+as a **step-by-step diagram reveal synced with voice narration** — the feel of
+a short Khan-Academy explainer, without generating any video and without any
+additional LLM call.
+
+This is the lightweight, in-IDE reimplementation of OpenMAIC's narrated-
+simulation idea. OpenMAIC itself is **not** integrated (AGPL-3.0, Node/LangGraph
+stack, classroom-app UX that would force a context switch out of the editor).
+
+## Non-Goals (explicit)
+
+- Games / quizzes / MCQs.
+- Video or talking-head avatar generation.
+- Server-side TTS (OpenAI/ElevenLabs/VoxCPM). Browser TTS only.
+- Any OpenMAIC integration.
+- Any change to the course-gen authoring pipeline (assignment generation,
+ judges, starters). Per standing rule, core course-gen edits are surfaced as
+ a flagged todo, not bundled here.
+- Any remote deployment. Build and verify locally only; no push to remote
+ without explicit approval (remote is under a deploy freeze).
+
+## Cost Model
+
+- Browser `SpeechSynthesis` (Web Speech API): $0. Local OS voice engine — not
+ an LLM, no API, no server, no network.
+- Mermaid render + step reveal animation: $0. Client-side only.
+- Narration script + diagram spec: LLM-generated, but **part of the existing
+ single tutor reply** — no new API call. Marginal change in output tokens
+ (a few hundred), roughly a wash with the prose it replaces.
+- Hard constraint: **single-pass only**. The script must be emitted in the
+ same tutor completion as the diagram. No second "rewrite into narration"
+ LLM pass.
+
+## Architecture
+
+Reuses the existing structured-fenced-block pattern already proven by the
+Mermaid feature (`app/static/lab-tutor.js` parses ```` ```mermaid ```` blocks
+and renders them via a client-side renderer).
+
+### Block format
+
+The tutor emits a fenced block tagged `lt-narrated` whose body is JSON:
+
+```json
+{
+ "steps": [
+ { "say": "First we chunk the documents.",
+ "mermaid": "flowchart LR\n A[Docs] --> B[Chunk]" },
+ { "say": "Each chunk is embedded into a vector.",
+ "mermaid": "flowchart LR\n A[Docs] --> B[Chunk] --> C[Embed]" }
+ ]
+}
+```
+
+- Each step carries its **own cumulative Mermaid source**: step N renders the
+ diagram containing nodes/edges 1..N.
+- Rationale for cumulative re-render over animating one base SVG: Mermaid
+ auto-generates SVG element ids, so reliably targeting "reveal node 3" by id
+ is brittle across diagram types and Mermaid versions. Re-rendering a small
+ diagram per step is cheap (client-side, sub-frame for <10 nodes) and
+ deterministic.
+- Recommended shape: 3–6 steps, one short sentence per `say`, small diagrams.
+
+### Widget renderer (`app/static/lab-tutor.js`)
+
+A new renderer registered alongside the existing mermaid renderer.
+
+- Parser: detect ```` ```lt-narrated ```` fences. Parse with a small
+ robustness ladder (LLMs occasionally emit trailing commas / stray prose):
+ `JSON.parse` → a minimal repair pass (strip trailing commas, trim to the
+ outermost `{...}`) → fallback card. Missing/empty `steps` → fallback card
+ showing the raw text (mirrors the existing `.lt-mermaid--failed` behavior).
+- Render: a card with a **▶ Play narration** button. No autoplay:
+ 1. Browsers gate audio/speech behind a user gesture.
+ 2. Surprise speech at a learner mid-task is poor UX.
+- Playback state machine per narrated card:
+ - `idle → playing → (paused) → done`
+ - On entering step i: render `steps[i].mermaid` into the card's diagram
+ slot; show `steps[i].say` as a visible caption; speak `say` via
+ `SpeechSynthesis`; on utterance `onend` advance to i+1; at end → `done`.
+ - **Stale-callback guard:** the `onend` handler must re-check the card's
+ `mode` before advancing. If the user paused/stopped between utterance
+ start and end, a late `onend` must NOT advance the cursor. (Borrowed
+ pattern: a single cursor advanced only by the active mode's callbacks.)
+- **Sync mechanism (validated approach): event-based, no timeline.** There
+ is no precomputed timeline or per-word timing. Advance is driven purely by
+ the speech `onend` event (or the no-audio timer below). Authoring controls
+ pacing purely by ordering/sizing steps. This is deliberately simpler than
+ timestamp scrubbing and needs zero duration metadata.
+- **No-audio / muted timer:** when muted or `speechSynthesis` is
+ unavailable, advance step i on `setTimeout` of
+ `max(2000ms, wordCount(say) * 240ms)` so the visual still paces sensibly.
+- **Per-step entry transition (polish):** when a step's diagram renders,
+ apply a short CSS entry transition on the diagram container
+ (opacity/translate/scale, ~150-250ms) so each reveal feels deliberate
+ rather than a hard swap. Pure CSS; no animation library.
+- Controls: Play/Pause, Prev, Next, Replay, Mute. Caption text is **always**
+ visible regardless of audio (accessibility, sound-off, and
+ `prefers-reduced-motion` users get a "show all steps" static fallback).
+- Persistence: last-played step index + mute state stored in the existing
+ enrollment-scoped localStorage chat record, so a narrated card survives
+ reload like chat messages already do.
+
+### Audio
+
+- `window.speechSynthesis` only.
+- Handle the `voiceschanged` load race (voices often unavailable on first
+ synchronous `getVoices()` call): resolve voice list on `voiceschanged` or a
+ short poll, pick a sane default (prefer a local en-US voice), fall back to
+ the platform default.
+- Mute toggle persisted per the persistence note above. Muted playback still
+ advances steps on a timer derived from `say` length (so the visual still
+ plays without audio).
+
+### Tutor prompt (`app/services/tutor_service.py`)
+
+Add guidance next to the existing Mermaid nudge in the persona/system prompt:
+
+- When the explanation is a **multi-step process** (pipeline, state machine,
+ data flow, algorithm walkthrough), prefer an `lt-narrated` block: 3–6 steps,
+ cumulative diagrams, one short sentence per step.
+- Keep the existing plain ```` ```mermaid ```` path for a single static
+ diagram. Keep the "don't diagram every reply" bar.
+- Emit the JSON in one reply (single-pass constraint).
+
+## Error Handling
+
+- Malformed `lt-narrated` JSON → fallback card with raw content + a short
+ error line. Never throw into the message log.
+- A step whose `mermaid` fails to render → show that step's `say` caption with
+ a small "diagram unavailable" note; narration/stepping continues.
+- `speechSynthesis` unavailable (older browser / blocked) → silent mode:
+ captions + manual Next still work; Mute control hidden or disabled.
+- Voices not yet loaded at Play time → defer first utterance until
+ `voiceschanged` (bounded wait), then proceed.
+
+## Testing / Verification
+
+- **Python (TDD):** assert the tutor service / persona contract — the system
+ prompt contains the `lt-narrated` guidance and the single-pass constraint;
+ cover any server-side parsing/validation we add for the block.
+- **JS (unit-testable seams):** factor the block parser and the playback
+ step-state reducer as pure functions so they can be unit-tested without a
+ DOM/browser; test malformed input, cumulative step progression, mute-timer
+ advance, persistence (de)serialization.
+- **Manual:** run a local code-server container with the widget injected,
+ trigger a narrated explanation, verify reveal+voice sync, controls,
+ reload persistence, and the no-audio fallback. Evidence before any
+ "it works" claim (verification-before-completion).
+
+## Techniques Borrowed from OpenMAIC (concepts only — AGPL, no code copied)
+
+OpenMAIC's implementation was studied (read-only) to de-risk this design.
+We adopt **patterns**, not code. Key validations and borrowed ideas:
+
+- **Event-based sequential cursor** (their `PlaybackEngine.processNext`):
+ speech actions block on the audio `ended` event before advancing; no
+ global timeline, no precomputed durations. This validated our core
+ approach and is the backbone of the playback loop.
+- **No-audio degradation timer:** their fallback of
+ `max(2s, words*240ms)` is adopted verbatim as our muted/no-TTS pacing.
+- **Mode-checked callbacks:** they re-check engine `mode` inside every
+ `onEnded` so a stale callback can't advance after pause/stop. Adopted as
+ our stale-callback guard.
+- **Per-element entry transition:** they reveal each whiteboard element with
+ a short staggered CSS/Framer entry transition rather than path-drawing.
+ We apply the same idea as a pure-CSS per-step container transition (we
+ re-render a cumulative Mermaid diagram instead of appending elements,
+ since Mermaid owns its SVG).
+- **Parser robustness ladder:** their `JSON.parse → jsonrepair →
+ partial-json` chain motivated our lighter `JSON.parse → minimal repair →
+ fallback card`.
+
+Explicitly NOT adopted: their full discriminated-union action engine,
+fire-and-forget action class, server-TTS pre-gen + IndexedDB audio cache,
+and multi-agent classroom machinery — all heavier than a single in-IDE
+narrated explanation needs (YAGNI for V1).
+
+## Rollout
+
+- Local only. No remote deploy. No push without explicit approval.
+- Feature is additive: if the tutor never emits `lt-narrated`, behavior is
+ unchanged from today.
diff --git a/pyproject.toml b/pyproject.toml
index a0949bc..9d3ba98 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,3 +18,6 @@ build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = ["app", "app.api", "app.domain", "app.services", "app.storage"]
+
+[tool.pytest.ini_options]
+pythonpath = ["."]
diff --git a/tests/js/test_narrated.js b/tests/js/test_narrated.js
new file mode 100644
index 0000000..514d9e0
--- /dev/null
+++ b/tests/js/test_narrated.js
@@ -0,0 +1,181 @@
+"use strict";
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const path = require("node:path");
+
+// Requiring lab-tutor.js under Node hits the export guard and returns the
+// pure helpers without touching the DOM.
+const lt = require(path.join(__dirname, "..", "..", "app", "static", "lab-tutor.js"));
+
+test("parseNarrated: valid JSON returns normalized steps", () => {
+ const body = JSON.stringify({
+ steps: [
+ { say: "Chunk the docs.", mermaid: "flowchart LR\n A-->B" },
+ { say: "Embed them.", mermaid: "flowchart LR\n A-->B-->C" },
+ ],
+ });
+ const out = lt.parseNarrated(body);
+ assert.equal(out.steps.length, 2);
+ assert.equal(out.steps[0].say, "Chunk the docs.");
+ assert.equal(out.steps[1].mermaid, "flowchart LR\n A-->B-->C");
+});
+
+test("parseNarrated: repairs trailing commas", () => {
+ const body = '{ "steps": [ { "say": "x", "mermaid": "graph TD\\n A", }, ], }';
+ const out = lt.parseNarrated(body);
+ assert.ok(out);
+ assert.equal(out.steps.length, 1);
+ assert.equal(out.steps[0].say, "x");
+});
+
+test("parseNarrated: strips stray prose around the object", () => {
+ const body = 'Sure! Here it is:\n{ "steps": [ { "say": "a", "mermaid": "graph TD\\n A" } ] }\nHope that helps.';
+ const out = lt.parseNarrated(body);
+ assert.ok(out);
+ assert.equal(out.steps[0].say, "a");
+});
+
+test("parseNarrated: garbage or empty -> null", () => {
+ assert.equal(lt.parseNarrated("not json at all <<<"), null);
+ assert.equal(lt.parseNarrated('{"steps": []}'), null);
+ assert.equal(lt.parseNarrated('{"steps": [{"say": 1}]}'), null);
+});
+
+test("computeNoAudioMs: max(2000, words*240)", () => {
+ assert.equal(lt.computeNoAudioMs("one two three"), 2000); // 3*240=720 -> floor 2000
+ assert.equal(lt.computeNoAudioMs("a ".repeat(20).trim()), 20 * 240);
+ assert.equal(lt.computeNoAudioMs(""), 2000);
+});
+
+test("narratedReducer: play/advance/done flow", () => {
+ let s = { mode: "idle", step: 0, total: 3 };
+ s = lt.narratedReducer(s, { type: "PLAY" });
+ assert.deepEqual(s, { mode: "playing", step: 0, total: 3 });
+ s = lt.narratedReducer(s, { type: "ADVANCE" });
+ assert.deepEqual(s, { mode: "playing", step: 1, total: 3 });
+ s = lt.narratedReducer(s, { type: "ADVANCE" });
+ s = lt.narratedReducer(s, { type: "ADVANCE" }); // step 2 -> done
+ assert.equal(s.mode, "done");
+ assert.equal(s.step, 2);
+});
+
+test("narratedReducer: stale ADVANCE ignored when not playing", () => {
+ let s = { mode: "paused", step: 1, total: 3 };
+ const after = lt.narratedReducer(s, { type: "ADVANCE" });
+ assert.deepEqual(after, s); // unchanged — stale-callback guard
+});
+
+test("narratedReducer: PAUSE/NEXT/PREV/REPLAY", () => {
+ let s = { mode: "playing", step: 0, total: 3 };
+ s = lt.narratedReducer(s, { type: "PAUSE" });
+ assert.equal(s.mode, "paused");
+ s = lt.narratedReducer(s, { type: "NEXT" });
+ assert.deepEqual(s, { mode: "paused", step: 1, total: 3 });
+ s = lt.narratedReducer(s, { type: "PREV" });
+ assert.deepEqual(s, { mode: "paused", step: 0, total: 3 });
+ s = lt.narratedReducer(s, { type: "PREV" }); // clamp at 0
+ assert.equal(s.step, 0);
+ s = lt.narratedReducer({ mode: "done", step: 2, total: 3 }, { type: "REPLAY" });
+ assert.deepEqual(s, { mode: "playing", step: 0, total: 3 });
+});
+
+test("parseNarrated: repairs literal newlines/tabs inside string values", () => {
+ // Raw newline + tab inside the mermaid value (invalid JSON as-is).
+ const body = '{"steps":[{"say":"Build it.","mermaid":"flowchart LR\n\tA-->B\n\tB-->C"}]}';
+ const out = lt.parseNarrated(body);
+ assert.ok(out, "expected a parsed object, got null");
+ assert.equal(out.steps.length, 1);
+ assert.equal(out.steps[0].say, "Build it.");
+ assert.equal(out.steps[0].mermaid, "flowchart LR\n\tA-->B\n\tB-->C");
+});
+
+test("narratedReducer: STOP resets to idle/step 0", () => {
+ const s = lt.narratedReducer({ mode: "playing", step: 2, total: 4 }, { type: "STOP" });
+ assert.deepEqual(s, { mode: "idle", step: 0, total: 4 });
+});
+
+test("extractNodeLabels: bracketed flowchart labels", () => {
+ const out = lt.extractNodeLabels("flowchart LR\n A[Query]-->B[Embed]");
+ assert.deepEqual(out.slice().sort(), ["Embed", "Query"]);
+});
+
+test("extractNodeLabels: bare ids when no label declared", () => {
+ const out = lt.extractNodeLabels("graph TD\n A-->B");
+ assert.deepEqual(out.slice().sort(), ["A", "B"]);
+});
+
+test("extractNodeLabels: ignores edge-label text and direction/header", () => {
+ const out = lt.extractNodeLabels("flowchart LR\n A[Q] -->|yes| B[E]");
+ assert.deepEqual(out.slice().sort(), ["E", "Q"]);
+ assert.equal(out.includes("yes"), false);
+ assert.equal(out.includes("LR"), false);
+ assert.equal(out.includes("flowchart"), false);
+});
+
+test("extractNodeLabels: dedupes by first declaration", () => {
+ const out = lt.extractNodeLabels("flowchart LR\n A[Q]-->B[E]\n B[E]-->A[Q]");
+ assert.deepEqual(out.slice().sort(), ["E", "Q"]);
+});
+
+test("extractNodeLabels: shape variants (rhombus/circle)", () => {
+ const out = lt.extractNodeLabels("flowchart TD\n A{Decide}-->B((Done))");
+ assert.deepEqual(out.slice().sort(), ["Decide", "Done"]);
+});
+
+test("extractNodeLabels: non-string / empty -> []", () => {
+ assert.deepEqual(lt.extractNodeLabels(""), []);
+ assert.deepEqual(lt.extractNodeLabels(null), []);
+});
+
+test("diffNewNodeLabels: no prev (step 0) -> []", () => {
+ assert.deepEqual(lt.diffNewNodeLabels(undefined, "flowchart LR\n A[Q]"), []);
+ assert.deepEqual(lt.diffNewNodeLabels("", "flowchart LR\n A[Q]"), []);
+});
+
+test("diffNewNodeLabels: returns only newly added label", () => {
+ const out = lt.diffNewNodeLabels(
+ "flowchart LR\n A[Q]-->B[E]",
+ "flowchart LR\n A[Q]-->B[E]-->C[S]"
+ );
+ assert.deepEqual(out, ["S"]);
+});
+
+test("diffNewNodeLabels: no change -> []", () => {
+ assert.deepEqual(
+ lt.diffNewNodeLabels("flowchart LR\n A[Q]", "flowchart LR\n A[Q]"),
+ []
+ );
+});
+
+test("extractNodeLabels: adversarial long word-run completes fast (no ReDoS)", () => {
+ const big = "flowchart LR\n A" + "x".repeat(20000) + " --> B[End]";
+ const t0 = Date.now();
+ const out = lt.extractNodeLabels(big);
+ const ms = Date.now() - t0;
+ assert.ok(Array.isArray(out), "must return an array, never throw/hang");
+ assert.ok(out.includes("End"), "still extracts the valid labeled node");
+ assert.ok(ms < 1000, "must complete well under 1s (was O(n^2) before cap), took " + ms + "ms");
+});
+
+test("extractNodeLabels: strips surrounding quotes (matches rendered text)", () => {
+ const out = lt.extractNodeLabels('flowchart LR\n A["Build Context"]-->B[Plain]');
+ assert.deepEqual(out.slice().sort(), ["Build Context", "Plain"]);
+});
+
+test("extractNodeLabels: single-quoted label", () => {
+ const out = lt.extractNodeLabels("flowchart LR\n A['Embed Query']");
+ assert.deepEqual(out, ["Embed Query"]);
+});
+
+test("extractNodeLabels: removes
tags from labels", () => {
+ const out = lt.extractNodeLabels("flowchart LR\n A[Line1
Line2]-->B[C
D]");
+ assert.deepEqual(out.slice().sort(), ["CD", "Line1Line2"]);
+});
+
+test("diffNewNodeLabels: quoted labels still diff correctly", () => {
+ const out = lt.diffNewNodeLabels(
+ 'flowchart LR\n A["Q"]-->B["E"]',
+ 'flowchart LR\n A["Q"]-->B["E"]-->C["Rerank Hits"]'
+ );
+ assert.deepEqual(out, ["Rerank Hits"]);
+});
diff --git a/tests/test_tutor_service.py b/tests/test_tutor_service.py
index 0982fd7..08dbae9 100644
--- a/tests/test_tutor_service.py
+++ b/tests/test_tutor_service.py
@@ -7,7 +7,7 @@
from unittest.mock import MagicMock
from app.domain.tutor import TutorChatRequest, TutorSubmitRequest, TutorTriageRequest
-from app.services.tutor_service import TutorService
+from app.services.tutor_service import TutorService, _TUTOR_PERSONA
def _make_fake_client(reply_text: str = "hello back") -> MagicMock:
@@ -277,5 +277,20 @@ def test_triage_uses_15s_timeout(self) -> None:
fake_client.with_options.assert_called_once_with(timeout=15.0)
+class TutorPersonaNarratedContract(unittest.TestCase):
+ def test_persona_documents_lt_narrated_and_single_pass(self) -> None:
+ self.assertIn("```lt-narrated", _TUTOR_PERSONA)
+ # JSON shape the widget parses
+ self.assertIn('"steps"', _TUTOR_PERSONA)
+ self.assertIn('"say"', _TUTOR_PERSONA)
+ self.assertIn('"mermaid"', _TUTOR_PERSONA)
+ # single-pass constraint must be stated
+ self.assertIn("same reply", _TUTOR_PERSONA.lower())
+ # plain mermaid path must still be documented
+ self.assertIn("```mermaid", _TUTOR_PERSONA)
+ # over-trigger guard: lt-narrated must be explicitly gated
+ self.assertIn("teaching point", _TUTOR_PERSONA)
+
+
if __name__ == "__main__":
unittest.main()