Bring Clicky's three interactive mechanics into the PDF Explainer lesson:
- The Pointing Tutor — the tutor's answer physically points at the right
regions on the PDF (Clicky's
[POINT:...]+ flying cursor). - Voice push-to-talk + spoken answers — hold a key to ask out loud; the
tutor pauses the lesson, answers in voice, and points (Clicky's push-to-talk
- ElevenLabs TTS → our Chirp TTS).
- Ask-about-this — click a region on the PDF and ask about exactly that (Clicky's screen-awareness → our stable page targets).
| Clicky primitive | PDF Explainer reuse |
|---|---|
| Screen it points at | Rendered PDF page + TargetAnchorLayer (DOM anchors per target) |
[POINT:x,y] pixels |
Stable target IDs with known boxes (p2_formula_001) — no coord guessing |
| Cursor bezier animation | OverlayLayer already flies spotlight/laser to any target |
| Streaming voice answers | The per-action Chirp TTS path (synthesizeCloudTts) |
| "What to point at" | aiTutor already returns referencedTargetKeys (currently unused in UI) |
| Interrupt / pause / resume | ActionEngine already has pause/resume/waiting_* states |
Current gap: AskTutorPanel and ScenePlayer are siblings under LessonPlayer
with no shared state, and the tutor reply is text-only.
A React context provided by LessonPlayer, consumed by AskTutorPanel,
ScenePlayer, and TargetAnchorLayer. It is the single coordination point so
the tutor (right panel) can drive the PDF stage (center).
interface TutorPoint {
targetKey: string; // must be a target on the currently-rendered page
say: string; // short spoken/explanatory chunk for this region
audioUrl?: string; // filled by Feature 2 (per-point Chirp clip)
duration?: number;
}
interface TutorInterjection {
messageId: string;
pageNumber: number; // the page these points live on
points: TutorPoint[];
activeIndex: number; // which point is showing
}
interface LessonInteractionState {
interjection: TutorInterjection | null;
focusedTargetKey: string | null; // Feature 3
explainMode: boolean; // Feature 3 (targets clickable)
startInterjection(i: Omit<TutorInterjection, "activeIndex">): void;
advancePoint(): void;
endInterjection(): void;
setFocusedTargetKey(key: string | null): void;
setExplainMode(on: boolean): void;
}Rule: while interjection !== null, the lesson ActionEngine is paused;
when it ends, the lesson resumes from exactly where it was.
ScenePlayer's PdfPageSceneRenderer already renders OverlayLayer +
TargetAnchorLayer. When an interjection is active for the current page, render
its active point as a normal TimedOverlay (no engine changes needed):
const interjectionOverlays: TimedOverlay[] = interjection
? [{ id: `tutor_${interjection.activeIndex}`, overlayType: "spotlight",
start: 0, end: 1e9, targetBlockKeys: [activePoint.targetKey] },
// + a callout overlay carrying activePoint.say next to the target
]
: engineOverlays;The engine is paused, so its overlays are inert; we swap in the interjection's overlays. This keeps the tutor pointing decoupled from the lesson engine (simpler than feeding transient actions into the engine; can be unified later).
Tutor answers about the current page drive the spotlight to each referenced region, stepping through the explanation. No voice required (that's Feature 2); F1 points + shows text and advances on a read-time timer or a "Next" control.
convex/aiTutor.ts — structured output + POINT protocol.
Replace the plain-text return with a Zod-validated JSON shape. Keep the rich
markdown answer for the chat bubble; add an ordered pointing sequence restricted
to current-page targets (visual pointing only works on the rendered page).
const TutorAnswerSchema = z.object({
answerMarkdown: z.string(),
sources: z.array(z.number()).default([]),
points: z.array(z.object({
targetId: z.string(), // MUST be a current-page targetKey from the prompt
say: z.string(), // one short spoken/explanatory chunk
})).default([]),
});Prompt additions:
- "You are given the targets on the CURRENT page (page X) with their IDs. If the
answer involves regions on page X, return
points: an ordered walkthrough where each entry names a target ID on page X and a shortsayexplaining it, in the order you'd point at them." - "Use ONLY target IDs that appear in the page-X target list. If the best answer
is on a different page, set
points: []and tell the student which page to open." (cross-page auto-nav is a later enhancement — see Risks.) - Switch the call to
responseMimeType: "application/json"+responseSchema, and validate withTutorAnswerSchema+ a one-shot repair (mirror thejsonRepair/repair pattern already used inactionGeneration.ts). - Drop the incidental
text.includes(key)echo detection (replaced bypoints). - Keep the "Sources:" extraction as a fallback if
sourcesis empty.
convex/chat.ts + convex/schema.ts. Add to chatMessages:
pointingSequenceJson: v.optional(v.string()), // JSON TutorPoint[]Thread it through _insertMessage and listMessages. Keep
referencedTargetKeys for back-compat (derive it from points).
convex/chatActions.ts — sendMessage. Persist the points:
const result = await askTutor({ ... });
await ctx.runMutation(internal.chat._insertMessage, {
...,
content: result.answerMarkdown,
sourcePages: result.sources,
referencedTargetKeys: result.points.map(p => p.targetId),
pointingSequenceJson: JSON.stringify(result.points),
});Validate every point.targetId against the current page's target keys
server-side (drop any the model invented) so the frontend never spotlights a
non-existent target.
LessonPlayerprovidesLessonInteractionContext(S1) and renders the center/right columns inside it. It owns the interjection state and, via a ref to the activeActionEngine(lifted fromScenePlayer, or exposed through a callback), pauses onstartInterjectionand resumes onendInterjection.AskTutorPanel— when an assistant message haspointingSequencewhosepageNumber === currentPageNumber, render a "Show me on the page" button on that bubble (and optionally auto-start once). Clicking callsstartInterjection({ messageId, pageNumber, points }).ScenePlayer— consume the context. When an interjection is active:- pause the engine,
- render interjection overlays (S2): spotlight to
activePoint.targetKeyviaOverlayLayer, plus aCalloutOverlaycarryingactivePoint.sayanchored beside the target, - show a small interjection control strip ("◀ ▶ point 2/4 ✕ resume lesson").
- advance: F1 auto-advances after a read-time delay
(
Math.max(2.2, words/2.6)s) or on the ▶ control; on the last point,endInterjection()resumes the lesson.
- Chat bubble polish — when a point is active, highlight the matching
saytext in the bubble (scroll-to + emphasis) so panel and page stay in sync.
- Points reference the current page only; if
pageNumber !== currentPageNumber(user navigated away), show "Open page N to see this" instead of pointing. - Empty
points→ behaves exactly like today (text answer only). - Target measured box missing (anchor not mounted) →
OverlayLayeralready falls back to the stored normalized box.
Ask a question about the current page → the lesson pauses, the spotlight flies to each referenced region in turn with its explanation, then the lesson resumes where it left off.
Adds Clicky's "real teacher next to you" loop: hold to talk, the tutor speaks
back while pointing. Composes with Feature 1: each point.say becomes its own
Chirp clip, so pointing is audio-synced with the same drift-proof model the
lesson already uses (visual drawn → its clip plays start-to-finish → advance).
Phase 2a (ship fast): browser SpeechRecognition. Zero infra; works in
Chrome/Edge. Hold-to-talk starts recognition, release finalizes the transcript.
Gate behind feature detection.
Phase 2b (robust, cross-browser): server STT. Mirror Clicky's proxy model
(keys server-side). Capture with MediaRecorder, POST the blob to a new Convex
action convex/transcription.ts → transcribeAudio that proxies to an STT
provider (OpenAI Whisper or AssemblyAI — Clicky uses AssemblyAI; key in Convex
env, same pattern as synthesizeCloudTts). Returns the transcript.
- Add a mic button + hold-key (e.g. hold
V) handler. ReuseScenePlayer's existing globalkeydown/keyupinfrastructure (it already manages player shortcuts and ignores typing in inputs). - Press →
engine.pause()+ show a recording indicator (reuse the waveform idea from Clicky's overlay, or a simple pulsing mic). Release → transcribe → callsendMessage({ question, currentPageNumber, ... }). - This is the browser analog of Clicky's
GlobalPushToTalkShortcutMonitor+BuddyDictationManager.
Reuse synthesizeCloudTts (Chirp). Synthesize one clip per point.say at
answer time inside sendMessage, storing audioUrl + duration on each point:
for (const point of result.points) {
const tts = await synthesizeCloudTts({ text: point.say });
const storageId = await ctx.storage.store(new Blob([tts.audioBytes], {type: tts.mimeType}));
point.audioUrl = await ctx.storage.getUrl(storageId);
point.duration = tts.durationSeconds;
}(Extract the existing storeAudioBytes helper from processing.ts into a shared
module so both pipelines use it.) Only point.say chunks get audio — not every
text answer — to control TTS quota. The full markdown answer keeps a separate
optional "🔊 play" button that synthesizes on demand.
The F1 interjection player now: for each point, draw the spotlight, play
point.audioUrl, advance on the audio ended event (exactly like
ActionEngine.notifySpeechEnded). Reuse a small inter-beat pause for the same
"point, then talk" feel. On completion, resume the lesson.
- Asking again (or pressing the hotkey) during an answer cancels the current interjection audio and starts the new turn.
- After the tutor finishes, the lesson resumes from its paused cursor.
- STT: 2a browser
SpeechRecognition; later 2btranscribeAudioaction. - Push-to-talk hotkey + mic button → pause lesson, capture, transcribe, send.
- Per-point Chirp TTS in
sendMessage; storeaudioUrl/durationon points. - Audio-driven interjection player (advance on
ended); optional "play answer". - Barge-in + resume.
Hold the mic key during a lesson → it pauses → speak a question → the tutor answers in voice while the spotlight points at each region → the lesson resumes.
Clicky sees exactly what you're looking at; here you click a region and the tutor centers its answer (and pointing) on that target.
TargetAnchorLayerinteractive mode. Today anchors are non-interactive. Add an "Explain mode" toggle inPlayerControls(or hold a modifier). When on, anchors become clickable with a hover affordance (cursor + a faint outline + a "?" badge). This avoids fighting the viewer's click-to-pause (whichScenePlayer.handleViewerClickowns) — pointing/explain is opt-in.- Selection → context. Clicking a target calls
setFocusedTargetKey(targetKey)(S1), switches the right panel to the Ask tab, and pre-seeds the input:Explain this: "<label>". - The answer comes back with
points(Feature 1) and naturally spotlights the selected region; with Feature 2 it also speaks.
chatActions.sendMessage + aiTutor.askTutor accept an optional
focusedTarget?: { targetKey, label, kind, text, pageNumber }. Inject into the
prompt: "The student is pointing at <targetKey> (<label>, a <kind>):
<text>. Center your answer and your first points entry on this region."
- Explain-mode toggle + clickable anchors with hover affordance.
- Thread
focusedTargetKeythrough the context → panel pre-seed +sendMessage. askTutorfocuses the answer/pointing on the selected target.
Turn on Explain mode, click a formula on the PDF → "Explain this" is pre-filled → the tutor answers about that formula and points right at it.
chatMessages.pointingSequenceJson(F1); per-pointaudioUrl/durationlive inside that JSON (F2). Add stored tutor-audio cleanup todocuments.tsdeleteDocumentand on chat-session reset.- Shared
storeAudioByteshelper extracted fromprocessing.ts.
- Feature 1 — backend POINT protocol +
LessonInteractionContext+ interjection overlay player. This is the foundation; nothing else works without "tutor can point". - Feature 2 — voice in/out; per-point Chirp TTS makes F1's pointing audio-synced. This is the biggest "wow", but depends on F1's interjection.
- Feature 3 — selection; smallest change, reuses F1's pointing and the shared context.
- Cross-page pointing. v1 points only on the current page; if the answer is
elsewhere, the tutor names the page. Later: auto-navigate to that page, then
point (the player already supports
onSelectChapter/ page jumps). - STT browser support.
SpeechRecognitionis Chrome/Edge-only; ship that first, add server STT (2b) for Safari/Firefox + quality. - TTS quota. Only
point.saychunks are synthesized, not full answers; one tutor turn ≈ a handful of short clips. Reuse the Chirp path/quota notes. - Click conflict. The viewer surface uses click-to-pause; target clicking is gated behind Explain mode so the two never fight.
- Latency. Synthesizing per-point audio at answer time adds a beat before the spoken walkthrough starts; show the text answer immediately and let audio catch up (text is ready before clips finish).
- Unit:
TutorAnswerSchemaparse + repair; server-side targetId validation (invented IDs dropped); read-time advance math. - Integration: ask → points stored → interjection plays + pauses/resumes lesson; focusedTarget biases the answer.
- Manual matrix: current-page question, cross-page question, empty-points (chit-chat) answer, voice round-trip, Explain-mode selection.