From b22f66f7d0d28fe041c4e4247e0ae8a4a6dd09b3 Mon Sep 17 00:00:00 2001 From: Tushar Bisht Date: Tue, 19 May 2026 11:06:14 +0530 Subject: [PATCH 01/19] docs(lab-tutor): narrated-whiteboard design spec Step-by-step Mermaid reveal synced with browser TTS. Single-pass LLM (no extra call), zero-cost browser SpeechSynthesis, in-widget. Explicit non-goals: games, video, server TTS, OpenMAIC, course-gen edits, remote deploy. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-16-narrated-whiteboard-design.md | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-16-narrated-whiteboard-design.md 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..826ba04 --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-narrated-whiteboard-design.md @@ -0,0 +1,145 @@ +# 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, `JSON.parse` the body. + Malformed JSON or missing/empty `steps` → graceful 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`. +- 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). + +## 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. From 8c75dd41296f2984a4f997bee68fb9f003ce63c2 Mon Sep 17 00:00:00 2001 From: Tushar Bisht Date: Tue, 19 May 2026 11:18:04 +0530 Subject: [PATCH 02/19] docs(lab-tutor): fold OpenMAIC research into narrated-whiteboard spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Studied OpenMAIC (read-only, AGPL — concepts only, no code). Validated the event-based sequential-cursor sync and adopted four refinements: no-audio timer max(2s, words*240ms), mode-checked stale-callback guard, per-step CSS entry transition, JSON parser robustness ladder. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-16-narrated-whiteboard-design.md | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-05-16-narrated-whiteboard-design.md b/docs/superpowers/specs/2026-05-16-narrated-whiteboard-design.md index 826ba04..57de14e 100644 --- a/docs/superpowers/specs/2026-05-16-narrated-whiteboard-design.md +++ b/docs/superpowers/specs/2026-05-16-narrated-whiteboard-design.md @@ -73,9 +73,11 @@ The tutor emits a fenced block tagged `lt-narrated` whose body is JSON: A new renderer registered alongside the existing mermaid renderer. -- Parser: detect ```` ```lt-narrated ```` fences, `JSON.parse` the body. - Malformed JSON or missing/empty `steps` → graceful fallback card showing the - raw text (mirrors the existing `.lt-mermaid--failed` behavior). +- 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. @@ -84,6 +86,22 @@ A new renderer registered alongside the existing mermaid renderer. - 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). @@ -138,6 +156,34 @@ Add guidance next to the existing Mermaid nudge in the persona/system prompt: 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. From e84fced6616da2a3d009a541ed3abb0382ea8b09 Mon Sep 17 00:00:00 2001 From: Tushar Bisht Date: Tue, 19 May 2026 11:23:56 +0530 Subject: [PATCH 03/19] docs(lab-tutor): narrated-whiteboard implementation plan TDD plan: Node-tested pure helpers (parser/timer/reducer), widget renderer + playback wiring, styles, single-pass persona guidance, manual browser verification. Local only, no deploy. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-16-narrated-whiteboard.md | 660 ++++++++++++++++++ 1 file changed, 660 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-16-narrated-whiteboard.md 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. From 7dd7eec64b1d81aebc4db92676d128f4546f07df Mon Sep 17 00:00:00 2001 From: Tushar Bisht Date: Tue, 19 May 2026 11:30:36 +0530 Subject: [PATCH 04/19] feat(lab-tutor): narrated-whiteboard pure helpers + Node unit tests Co-Authored-By: Claude Opus 4.7 (1M context) --- app/static/lab-tutor.js | 74 ++++++++++++++++++++++++++++++++++++ tests/js/test_narrated.js | 80 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 tests/js/test_narrated.js diff --git a/app/static/lab-tutor.js b/app/static/lab-tutor.js index 43a632a..eaaf946 100644 --- a/app/static/lab-tutor.js +++ b/app/static/lab-tutor.js @@ -10,6 +10,80 @@ (function () { "use strict"; + // ── 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; + } + // ── Config from script tag ──────────────────────────────────────────────── const me = document.currentScript || document.querySelector('script[src*="lab-tutor.js"]'); diff --git a/tests/js/test_narrated.js b/tests/js/test_narrated.js new file mode 100644 index 0000000..b330734 --- /dev/null +++ b/tests/js/test_narrated.js @@ -0,0 +1,80 @@ +"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 }); +}); From a3f5b2ecbd096d16b8a54bb21ede97d837fdeb5a Mon Sep 17 00:00:00 2001 From: Tushar Bisht Date: Tue, 19 May 2026 11:36:22 +0530 Subject: [PATCH 05/19] fix(lab-tutor): repair raw newlines/tabs inside lt-narrated JSON strings LLMs commonly emit literal newlines in multiline string values (mermaid source). Escape control chars only when inside a JSON string literal so parseNarrated no longer falls back to null on well-meaning but unescaped output. Adds STOP reducer test. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/static/lab-tutor.js | 21 +++++++++++++++++++++ tests/js/test_narrated.js | 15 +++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/app/static/lab-tutor.js b/app/static/lab-tutor.js index eaaf946..adf4a00 100644 --- a/app/static/lab-tutor.js +++ b/app/static/lab-tutor.js @@ -11,6 +11,26 @@ "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) { @@ -23,6 +43,7 @@ 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; diff --git a/tests/js/test_narrated.js b/tests/js/test_narrated.js index b330734..005d914 100644 --- a/tests/js/test_narrated.js +++ b/tests/js/test_narrated.js @@ -78,3 +78,18 @@ test("narratedReducer: PAUSE/NEXT/PREV/REPLAY", () => { 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 }); +}); From e572db8451b368c1bde74ed30954dc3618007ecf Mon Sep 17 00:00:00 2001 From: Tushar Bisht Date: Tue, 19 May 2026 11:40:58 +0530 Subject: [PATCH 06/19] feat(lab-tutor): narrated-whiteboard renderer + playback wiring Co-Authored-By: Claude Opus 4.7 (1M context) --- app/static/lab-tutor.js | 168 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 163 insertions(+), 5 deletions(-) diff --git a/app/static/lab-tutor.js b/app/static/lab-tutor.js index adf4a00..c1e35ca 100644 --- a/app/static/lab-tutor.js +++ b/app/static/lab-tutor.js @@ -235,6 +235,150 @@ } } + + // 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); + } + // ── SVG helpers ─────────────────────────────────────────────────────────── function chatIcon() { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); @@ -471,8 +615,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; @@ -481,10 +626,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); From ea2f72d942ed0fb9e9613352892cedb8ff40dd44 Mon Sep 17 00:00:00 2001 From: Tushar Bisht Date: Tue, 19 May 2026 11:50:03 +0530 Subject: [PATCH 07/19] fix(lab-tutor): prevent narrated playback freeze on mute mid-speech Hoist advance() to card scope so muting during active TTS restarts the no-audio pacing timer instead of depending on cancel() firing onerror (browser-dependent). Drop redundant tokenMode capture and unused narratedIdCounter. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/static/lab-tutor.js | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/app/static/lab-tutor.js b/app/static/lab-tutor.js index c1e35ca..b26a075 100644 --- a/app/static/lab-tutor.js +++ b/app/static/lab-tutor.js @@ -237,7 +237,6 @@ // 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"; @@ -313,18 +312,18 @@ nextBtn.disabled = state.step >= total - 1 && state.mode !== "playing"; } + function advance() { + state = narratedReducer(state, { type: "ADVANCE" }); + if (state.mode === "playing") { + run(); + } else { + syncControls(); + } + } + 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(); }; @@ -372,7 +371,17 @@ muted = !muted; muteBtn.textContent = muted ? "🔇" : "🔊"; muteBtn.setAttribute("aria-label", muted ? "Unmute narration" : "Mute narration"); - if (muted) stopSpeech(); + 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). From 4e082fde9749b0ac7035770e4ebd5787ff32aeee Mon Sep 17 00:00:00 2001 From: Tushar Bisht Date: Tue, 19 May 2026 11:52:29 +0530 Subject: [PATCH 08/19] feat(lab-tutor): narrated-whiteboard styles + reduced-motion fallback Co-Authored-By: Claude Opus 4.7 (1M context) --- app/static/lab-tutor.css | 66 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/app/static/lab-tutor.css b/app/static/lab-tutor.css index c60a5cf..a51b379 100644 --- a/app/static/lab-tutor.css +++ b/app/static/lab-tutor.css @@ -392,3 +392,69 @@ 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(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; +} From dc6d56715e899b15e46690c80a85685c33f2e5f0 Mon Sep 17 00:00:00 2001 From: Tushar Bisht Date: Tue, 19 May 2026 11:56:27 +0530 Subject: [PATCH 09/19] feat(lab-tutor): persona guidance for lt-narrated (single-pass) Co-Authored-By: Claude Opus 4.7 (1M context) --- app/services/tutor_service.py | 10 +++++++++- pyproject.toml | 3 +++ tests/test_tutor_service.py | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/services/tutor_service.py b/app/services/tutor_service.py index d1e2936..15989ca 100644 --- a/app/services/tutor_service.py +++ b/app/services/tutor_service.py @@ -41,7 +41,15 @@ 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]"} + ]} + ``` + 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.""" _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/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/test_tutor_service.py b/tests/test_tutor_service.py index 0982fd7..e42089f 100644 --- a/tests/test_tutor_service.py +++ b/tests/test_tutor_service.py @@ -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: + 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) + + if __name__ == "__main__": unittest.main() From c04e254a4f9215a647a942e8d5072d6c01b9ce06 Mon Sep 17 00:00:00 2001 From: Tushar Bisht Date: Tue, 19 May 2026 12:03:50 +0530 Subject: [PATCH 10/19] fix(lab-tutor): resolve narrated-bullet vs brevity-rule tension; harden test State an explicit exception so the model doesn't truncate narrated blocks to obey the 2-4 sentence rule; expand example to 3 steps to match the stated 3-6 minimum; assert the over-trigger guard phrase; move _TUTOR_PERSONA import to module level. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/services/tutor_service.py | 5 +++-- tests/test_tutor_service.py | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/services/tutor_service.py b/app/services/tutor_service.py index 15989ca..fb3a31b 100644 --- a/app/services/tutor_service.py +++ b/app/services/tutor_service.py @@ -46,10 +46,11 @@ ```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":"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.""" + 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/tests/test_tutor_service.py b/tests/test_tutor_service.py index e42089f..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: @@ -279,8 +279,6 @@ def test_triage_uses_15s_timeout(self) -> None: 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) @@ -290,6 +288,8 @@ def test_persona_documents_lt_narrated_and_single_pass(self) -> None: 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__": From beff96c9a4620426b8b16ed3ad2b2921de47eeb6 Mon Sep 17 00:00:00 2001 From: Tushar Bisht Date: Tue, 19 May 2026 12:24:48 +0530 Subject: [PATCH 11/19] docs(lab-tutor): attention-polish design spec (Bundle A) New-node spotlight (source-diff + label-text match, graceful no-op), motion polish, keyboard + progress dots. No voice/LLM/persistence/infra changes. Local only. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ated-whiteboard-attention-polish-design.md | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-16-narrated-whiteboard-attention-polish-design.md 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. From 528751020694466f818d7bca9194374b7eff50a0 Mon Sep 17 00:00:00 2001 From: Tushar Bisht Date: Tue, 19 May 2026 12:31:37 +0530 Subject: [PATCH 12/19] docs(lab-tutor): attention-polish implementation plan (Bundle A) 4 TDD tasks: node-tested label extract/diff helpers, spotlight DOM + spring motion CSS, card-scoped keyboard + progress dots, regression + manual verification. Local only, no deploy. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...16-narrated-whiteboard-attention-polish.md | 682 ++++++++++++++++++ 1 file changed, 682 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-16-narrated-whiteboard-attention-polish.md 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(/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. From 12a3cb37d0339c494335419e4d72ffd8b49a0211 Mon Sep 17 00:00:00 2001 From: Tushar Bisht Date: Tue, 19 May 2026 12:34:01 +0530 Subject: [PATCH 13/19] feat(lab-tutor): node-label extraction + diff helpers for spotlight Co-Authored-By: Claude Opus 4.7 (1M context) --- app/static/lab-tutor.js | 78 ++++++++++++++++++++++++++++++++++++++- tests/js/test_narrated.js | 53 ++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) diff --git a/app/static/lab-tutor.js b/app/static/lab-tutor.js index b26a075..ad59233 100644 --- a/app/static/lab-tutor.js +++ b/app/static/lab-tutor.js @@ -98,10 +98,86 @@ } } + // 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(/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 }; + module.exports = { parseNarrated, computeNoAudioMs, narratedReducer, extractNodeLabels, diffNewNodeLabels }; return; } diff --git a/tests/js/test_narrated.js b/tests/js/test_narrated.js index 005d914..dc9fe08 100644 --- a/tests/js/test_narrated.js +++ b/tests/js/test_narrated.js @@ -93,3 +93,56 @@ 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]"), + [] + ); +}); From f27928122b4cb205140f83fcdf5f682444bb6319 Mon Sep 17 00:00:00 2001 From: Tushar Bisht Date: Tue, 19 May 2026 12:46:03 +0530 Subject: [PATCH 14/19] fix(lab-tutor): cap node-id regex to kill ReDoS; drop bogus DT keyword Unbounded \w* in the SHAPES patterns backtracks O(n^2) on long word-char runs; cap to \w{0,63} (Mermaid ids are short). Remove non-existent DT direction keyword. Add ReDoS regression test + an invariant comment on the exec/replace two-buffer dance. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/static/lab-tutor.js | 29 ++++++++++++++++++----------- tests/js/test_narrated.js | 10 ++++++++++ 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/app/static/lab-tutor.js b/app/static/lab-tutor.js index ad59233..3c9e6a4 100644 --- a/app/static/lab-tutor.js +++ b/app/static/lab-tutor.js @@ -109,20 +109,23 @@ "class", "linkStyle", "click", "direction", "stateDiagram", "stateDiagram-v2", "sequenceDiagram", "classDiagram", "erDiagram", "journey", "gantt", "pie", "mindmap", - "LR", "RL", "TB", "BT", "TD", "DT", + "LR", "RL", "TB", "BT", "TD", ]); // Most-specific bracket pairs first so e.g. ([stadium]) is not - // mis-read by the (round) pattern. + // 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*)\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] + /([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 = []; @@ -139,6 +142,10 @@ lines[0] = ""; } work = lines.join("\n"); + // 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; diff --git a/tests/js/test_narrated.js b/tests/js/test_narrated.js index dc9fe08..5cbad26 100644 --- a/tests/js/test_narrated.js +++ b/tests/js/test_narrated.js @@ -146,3 +146,13 @@ test("diffNewNodeLabels: no change -> []", () => { [] ); }); + +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"); +}); From 52fc91260d869f1f48c1fb2a63587849186cb095 Mon Sep 17 00:00:00 2001 From: Tushar Bisht Date: Tue, 19 May 2026 12:49:23 +0530 Subject: [PATCH 15/19] feat(lab-tutor): spotlight the newly added node + spring step-in Co-Authored-By: Claude Opus 4.7 (1M context) --- app/static/lab-tutor.css | 25 +++++++++++++++++++++++-- app/static/lab-tutor.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/app/static/lab-tutor.css b/app/static/lab-tutor.css index a51b379..87843ff 100644 --- a/app/static/lab-tutor.css +++ b/app/static/lab-tutor.css @@ -403,12 +403,30 @@ } .lt-narrated-stage { opacity: 0; - transform: translateY(6px) scale(0.98); + transform: translateY(10px) scale(0.96); } .lt-narrated-stage--in { opacity: 1; transform: none; - transition: opacity 0.22s ease, transform 0.22s ease; + 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, @@ -417,6 +435,9 @@ transform: none; transition: none; } + .lt-narrated-spot { + animation: none; + } } .lt-narrated-stage svg { max-width: 100%; diff --git a/app/static/lab-tutor.js b/app/static/lab-tutor.js index 3c9e6a4..28b5078 100644 --- a/app/static/lab-tutor.js +++ b/app/static/lab-tutor.js @@ -369,6 +369,33 @@ 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"); + 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; @@ -378,6 +405,7 @@ void stage.offsetWidth; await renderMermaidInto(stage, step.mermaid); stage.classList.add("lt-narrated-stage--in"); + spotlightNew(i); } function syncControls() { From af8423b1e0fa25de73f812b3ff6a94dc7055550e Mon Sep 17 00:00:00 2001 From: Tushar Bisht Date: Tue, 19 May 2026 12:55:59 +0530 Subject: [PATCH 16/19] docs(lab-tutor): explain intentional spotlight selector breadth Co-Authored-By: Claude Opus 4.7 (1M context) --- app/static/lab-tutor.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/static/lab-tutor.js b/app/static/lab-tutor.js index 28b5078..d037eeb 100644 --- a/app/static/lab-tutor.js +++ b/app/static/lab-tutor.js @@ -378,6 +378,9 @@ ); 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(); From a82b418cc9489c9008c38f964c18462fd2423759 Mon Sep 17 00:00:00 2001 From: Tushar Bisht Date: Tue, 19 May 2026 12:57:45 +0530 Subject: [PATCH 17/19] feat(lab-tutor): card-scoped keyboard control + progress dots Co-Authored-By: Claude Opus 4.7 (1M context) --- app/static/lab-tutor.css | 21 +++++++++++++++++ app/static/lab-tutor.js | 49 +++++++++++++++++++++++++++++++++++----- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/app/static/lab-tutor.css b/app/static/lab-tutor.css index 87843ff..99cde1a 100644 --- a/app/static/lab-tutor.css +++ b/app/static/lab-tutor.css @@ -452,6 +452,27 @@ 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; diff --git a/app/static/lab-tutor.js b/app/static/lab-tutor.js index d037eeb..58a100a 100644 --- a/app/static/lab-tutor.js +++ b/app/static/lab-tutor.js @@ -332,6 +332,18 @@ 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) => { @@ -424,6 +436,9 @@ ); 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() { @@ -458,7 +473,7 @@ if (state.mode === "playing") speakCurrent(); } - playBtn.addEventListener("click", () => { + function doPlayPause() { if (state.mode === "playing") { state = narratedReducer(state, { type: "PAUSE" }); stopSpeech(); @@ -468,19 +483,23 @@ state = narratedReducer(state, { type: wasDone ? "REPLAY" : "PLAY" }); run(); } - }); - prevBtn.addEventListener("click", () => { + } + function doPrev() { stopSpeech(); state = narratedReducer(state, { type: "PREV" }); if (state.mode === "playing") { state = narratedReducer(state, { type: "PAUSE" }); } renderStep(state.step).then(syncControls); - }); - nextBtn.addEventListener("click", () => { + } + 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 ? "🔇" : "🔊"; @@ -498,6 +517,24 @@ } }); + // 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); } From 57a2a9ccbcdd905a489fdb8c27b6170dd7eb6171 Mon Sep 17 00:00:00 2001 From: Tushar Bisht Date: Tue, 19 May 2026 13:03:33 +0530 Subject: [PATCH 18/19] fix(lab-tutor): stop Space double-firing when a control button is focused Card keydown deferred to the button's native Space activation only for Space; arrows still drive prev/next with a button focused. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/static/lab-tutor.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/static/lab-tutor.js b/app/static/lab-tutor.js index 58a100a..5650e8b 100644 --- a/app/static/lab-tutor.js +++ b/app/static/lab-tutor.js @@ -523,7 +523,12 @@ 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") { From 8268ef6aef3717d0b72c31f8c08bb10886167b8e Mon Sep 17 00:00:00 2001 From: Tushar Bisht Date: Tue, 19 May 2026 13:09:15 +0530 Subject: [PATCH 19/19] fix(lab-tutor): normalize quoted/
node labels so spotlight matches LLM-emitted Mermaid often quotes multi-word labels; Mermaid renders them without quotes/tags, so the raw-source label never matched the SVG textContent and the spotlight silently no-opped. Normalize on extract (both sides of the diff stay consistent). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/static/lab-tutor.js | 24 +++++++++++++++++++++++- tests/js/test_narrated.js | 23 +++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/app/static/lab-tutor.js b/app/static/lab-tutor.js index 5650e8b..ccccabb 100644 --- a/app/static/lab-tutor.js +++ b/app/static/lab-tutor.js @@ -102,6 +102,23 @@ // 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([ @@ -142,6 +159,11 @@ 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 @@ -152,7 +174,7 @@ let m; while ((m = re.exec(work)) !== null) { const id = m[1]; - const label = m[2].trim(); + const label = normalizeLabel(m[2]); if (!(id in labelById) && label !== "") { labelById[id] = label; order.push(id); diff --git a/tests/js/test_narrated.js b/tests/js/test_narrated.js index 5cbad26..514d9e0 100644 --- a/tests/js/test_narrated.js +++ b/tests/js/test_narrated.js @@ -156,3 +156,26 @@ test("extractNodeLabels: adversarial long word-run completes fast (no ReDoS)", ( 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"]); +});