Revisione/app/pages/learn/[id]/index.vue

2120 lines
66 KiB
Vue

<script setup lang="ts">
definePageMeta({ layout: false });
import confetti from "canvas-confetti";
const route = useRoute();
const topicId = route.params.id as string;
// ── JIT generation state ───────────────────────────────────────────────────
type GenState = "loading" | "ready" | "error";
const lesson = ref<any>(null);
const lessonPending = ref(false);
const lessonError = ref<any>(null);
const genState = ref<GenState | null>(null);
const abortController = ref<AbortController | null>(null);
const generationFired = ref(false);
const GEN_TIPS = [
"Reading through your past papers...",
"Crafting analogies just for this topic...",
"Writing your lesson from scratch...",
"Recording your audio narration...",
"Almost there...",
];
const GEN_STAGES = [
"Understanding the topic",
"Writing your lesson",
"Recording narration",
"Finishing up",
];
const genTipIndex = ref(0);
const genTipVisible = ref(true);
const genStageIndex = ref(0);
let genTipTimer: ReturnType<typeof setInterval> | null = null;
let genStageTimer: ReturnType<typeof setInterval> | null = null;
function startGenAnimations() {
if (genTipTimer) return;
genTipTimer = setInterval(() => {
genTipVisible.value = false;
setTimeout(() => {
genTipIndex.value = (genTipIndex.value + 1) % GEN_TIPS.length;
genTipVisible.value = true;
}, 300);
}, 3000);
genStageTimer = setInterval(() => {
genStageIndex.value = (genStageIndex.value + 1) % GEN_STAGES.length;
}, 4500);
}
function stopGenAnimations() {
if (genTipTimer) { clearInterval(genTipTimer); genTipTimer = null; }
if (genStageTimer) { clearInterval(genStageTimer); genStageTimer = null; }
}
async function loadLesson() {
lesson.value = null;
const pendingTimer = setTimeout(() => { lessonPending.value = true; }, 100);
abortController.value = new AbortController();
try {
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
lesson.value = data;
genState.value = "ready";
} catch (err: any) {
if (err?.statusCode === 404 || err?.status === 404) {
// lesson not generated yet — trigger JIT generation
await triggerGeneration();
} else {
lessonError.value = err;
}
} finally {
clearTimeout(pendingTimer);
lessonPending.value = false;
}
}
async function triggerGeneration() {
if (generationFired.value) return;
generationFired.value = true;
genState.value = "loading";
startGenAnimations();
try {
const result = await $fetch<{ status: string }>(`/api/topics/${topicId}/generate`, { method: "POST" });
stopGenAnimations();
if (result.status === "ready") {
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
lesson.value = data;
genState.value = "ready";
} else {
genState.value = "error";
}
} catch {
stopGenAnimations();
genState.value = "error";
}
}
onMounted(loadLesson);
onUnmounted(() => {
stopGenAnimations();
abortController.value?.abort();
});
// ── branch loading overlay ─────────────────────────────────────────────────
const branchOverlayVisible = ref(false);
const branchOverlayError = ref(false);
let branchPollTimer: ReturnType<typeof setInterval> | null = null;
const branchPollStart = ref(0);
function startBranchPoll() {
if (branchPollTimer) return;
branchPollStart.value = Date.now();
branchPollTimer = setInterval(async () => {
try {
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
if (data.branchStatus === "ready") {
lesson.value = data;
stopBranchPoll();
branchOverlayVisible.value = false;
} else if (data.branchStatus === "error") {
stopBranchPoll();
branchOverlayError.value = true;
}
} catch { /* ignore poll errors */ }
if (Date.now() - branchPollStart.value > 60_000) {
stopBranchPoll();
branchOverlayError.value = true;
}
}, 2000);
}
function stopBranchPoll() {
if (branchPollTimer) { clearInterval(branchPollTimer); branchPollTimer = null; }
}
// ── lesson state machine ───────────────────────────────────────────────────
type LessonMode = "main" | "transition" | "branch" | "confirm" | "hardStop";
const lessonState = reactive({
mode: "main" as LessonMode,
stepIndex: 0,
branchOption: "" as string,
branchStepIndex: 0,
});
type Step = {
type: "concept" | "example" | "question" | "summary"
title?: string
body?: string
callout?: string
options?: string[]
answer?: string
explanation?: string
bullets?: string[]
audioPath?: string
audioChunks?: { text: string; start: number; end: number }[]
questionAudioPath?: string
questionAudioChunks?: { text: string; start: number; end: number }[]
optionAudioPaths?: (string | null)[]
branches?: Record<string, BranchData>
}
interface BranchData {
steps: BranchStep[]
confirmQuestion: {
body: string
options: string[]
answer: string
explanation: string
questionAudioPath?: string
questionAudioChunks?: { text: string; start: number; end: number }[]
optionAudioPaths?: (string | null)[]
}
hardStop: string
hardStopAudioPath?: string
}
interface BranchStep {
type: "concept"
title: string
body: string
audioPath?: string
audioChunks?: { text: string; start: number; end: number }[]
}
const steps = computed<Step[]>(() => lesson.value?.content?.steps ?? []);
const totalSteps = computed(() => steps.value.length);
const isLastMainStep = computed(() => lessonState.stepIndex === totalSteps.value - 1);
// ── current display step ───────────────────────────────────────────────────
const currentBranch = computed<BranchData | null>(() => {
if (!["branch", "confirm", "hardStop"].includes(lessonState.mode)) return null;
const mainStep = steps.value[lessonState.stepIndex];
return mainStep?.branches?.[lessonState.branchOption] ?? null;
});
const currentBranchStep = computed<BranchStep | null>(() => {
if (lessonState.mode !== "branch") return null;
return currentBranch.value?.steps[lessonState.branchStepIndex] ?? null;
});
const displayStep = computed<Step | null>(() => {
const s = lessonState;
if (s.mode === "main") return steps.value[s.stepIndex] ?? null;
if (s.mode === "branch") {
const bs = currentBranchStep.value;
if (!bs) return null;
return { type: "concept", title: bs.title, body: bs.body, audioPath: bs.audioPath, audioChunks: bs.audioChunks };
}
if (s.mode === "confirm") {
const cq = currentBranch.value?.confirmQuestion;
if (!cq) return null;
return {
type: "question",
body: cq.body,
options: cq.options,
answer: cq.answer,
explanation: cq.explanation,
questionAudioPath: cq.questionAudioPath,
questionAudioChunks: cq.questionAudioChunks,
optionAudioPaths: cq.optionAudioPaths,
};
}
return null;
});
const stepKey = computed(() =>
`${lessonState.mode}-${lessonState.stepIndex}-${lessonState.branchOption}-${lessonState.branchStepIndex}`
);
const stepBg = computed(() => {
if (lessonPending.value || lessonError.value) return "#FAFAF8";
if (lessonState.mode === "confirm") return "#FFFBEB";
if (lessonState.mode === "branch") return "#FAFAF8";
if (lessonState.mode === "transition") return "#FFFBEB";
if (lessonState.mode === "hardStop") return "#FFFBEB";
const map: Record<string, string> = {
concept: "#FAFAF8", example: "#FFFBEB", question: "#F0F4FF", summary: "#F0FDF4",
};
return displayStep.value ? (map[displayStep.value.type] ?? "#FAFAF8") : "#FAFAF8";
});
const accentColor = computed(() => {
if (lessonState.mode === "confirm") return "#F59E0B";
if (lessonState.mode === "branch") return "#F59E0B";
if (!displayStep.value) return "#6366F1";
const map: Record<string, string> = {
concept: "#6366F1", example: "#F59E0B", question: "#6366F1", summary: "#10B981",
};
return map[displayStep.value.type] ?? "#6366F1";
});
const typeLabel = computed(() => {
if (lessonState.mode === "confirm") return "Let's Check";
if (lessonState.mode === "branch") return "A Closer Look";
const map: Record<string, string> = {
concept: "Concept", example: "Worked Example", question: "Question", summary: "Key Takeaways",
};
return displayStep.value ? (map[displayStep.value.type] ?? "") : "";
});
// ── answer tracking ────────────────────────────────────────────────────────
// main question answers, keyed by step index
const selectedAnswers = ref<Record<number, string>>({});
// confirm question answer (one at a time)
const confirmSelectedAnswer = ref<string | null>(null);
const selectedAnswer = computed(() => {
if (lessonState.mode === "main") return selectedAnswers.value[lessonState.stepIndex] ?? null;
if (lessonState.mode === "confirm") return confirmSelectedAnswer.value;
return null;
});
const hasAnswered = computed(() => selectedAnswer.value !== null);
const isCorrect = computed(() => {
if (!hasAnswered.value || !selectedAnswer.value) return null;
return selectedAnswer.value === displayStep.value?.answer;
});
const canContinue = computed(() => {
if (!displayStep.value) return false;
if (displayStep.value.type === "question") return showMainExplanation.value;
if (lessonState.mode === "branch") return true;
return lessonState.mode === "main";
});
// show explanation: main question — only for correct answers or wrong with no branch
const showMainExplanation = computed(() => {
if (lessonState.mode !== "main") return false;
if (!hasAnswered.value) return false;
if (isCorrect.value) return true;
// wrong: only show if no branch exists for this option
const mainStep = steps.value[lessonState.stepIndex];
const sel = selectedAnswers.value[lessonState.stepIndex];
return !mainStep?.branches?.[sel];
});
// ── shuffled options ───────────────────────────────────────────────────────
const shuffledOptions = ref<string[]>([]);
function seededShuffle(arr: string[], seed: number): string[] {
const a = [...arr];
let s = seed;
for (let i = a.length - 1; i > 0; i--) {
s = (s * 1664525 + 1013904223) >>> 0;
const j = s % (i + 1);
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
watch([() => stepKey.value, steps], () => {
const s = displayStep.value;
if (s?.type === "question" && s.options) {
const seed = lessonState.stepIndex * 31 + (lessonState.branchOption ? lessonState.branchOption.charCodeAt(0) : 0);
shuffledOptions.value = seededShuffle(s.options, seed);
} else {
shuffledOptions.value = [];
}
}, { immediate: true });
const LABELS = ["A", "B", "C", "D"];
// ── branch tracking ────────────────────────────────────────────────────────
const pendingBranchStep = ref<number | null>(null);
const pendingBranchOption = ref<string>("");
// when branch overlay dismisses after poll completes, fire the deferred branch
watch(branchOverlayVisible, (visible) => {
if (!visible && pendingBranchStep.value !== null && !branchOverlayError.value) {
const step = pendingBranchStep.value;
const opt = pendingBranchOption.value;
pendingBranchStep.value = null;
pendingBranchOption.value = "";
enterBranchMode(step, opt);
}
});
const branchesEntered = ref(0);
let transitionTimeout: ReturnType<typeof setTimeout> | null = null;
let confirmAdvanceTimeout: ReturnType<typeof setTimeout> | null = null;
function enterBranchMode(stepIndex: number, wrongOption: string) {
// if branches arent ready yet, show overlay and wait
if (lesson.value?.branchStatus !== "ready") {
branchOverlayVisible.value = true;
branchOverlayError.value = false;
startBranchPoll();
// store where we want to go when branches become available
pendingBranchStep.value = stepIndex;
pendingBranchOption.value = wrongOption;
return;
}
branchesEntered.value++;
lessonState.mode = "transition";
lessonState.stepIndex = stepIndex;
lessonState.branchOption = wrongOption;
if (focusMode.value) {
stopAllAudio();
const audio = new Audio("/audio/branch-transition.mp3");
audio.play().catch(() => {});
}
transitionTimeout = setTimeout(() => {
lessonState.mode = "branch";
lessonState.branchStepIndex = 0;
confirmSelectedAnswer.value = null;
if (focusMode.value) {
nextTick(() => startStepAudio());
}
}, 1500);
}
function advanceBranchStep() {
const branch = currentBranch.value;
if (!branch) return;
if (lessonState.branchStepIndex < branch.steps.length - 1) {
lessonState.branchStepIndex++;
if (focusMode.value) nextTick(() => startStepAudio());
} else {
// last branch step → go to confirm
lessonState.mode = "confirm";
confirmSelectedAnswer.value = null;
if (focusMode.value) nextTick(() => startStepAudio());
}
}
function selectConfirmOption(opt: string) {
if (confirmSelectedAnswer.value) return;
confirmSelectedAnswer.value = opt;
if (opt === currentBranch.value?.confirmQuestion?.answer) {
// correct — advance to next main step after 1.5s
confirmAdvanceTimeout = setTimeout(() => {
const nextStep = lessonState.stepIndex + 1;
lessonState.mode = "main";
lessonState.branchOption = "";
lessonState.branchStepIndex = 0;
if (nextStep < totalSteps.value) {
lessonState.stepIndex = nextStep;
} else {
lessonState.stepIndex = totalSteps.value - 1;
}
confirmSelectedAnswer.value = null;
if (focusMode.value) nextTick(() => startStepAudio());
}, 1500);
} else {
// wrong → hard stop after brief moment
setTimeout(() => {
lessonState.mode = "hardStop";
if (focusMode.value && currentBranch.value?.hardStopAudioPath) {
stopAllAudio();
const audio = new Audio(currentBranch.value.hardStopAudioPath);
audio.play().catch(() => {});
}
}, 700);
}
}
function retryFromHardStop() {
const stepIndex = lessonState.stepIndex;
lessonState.mode = "main";
lessonState.branchOption = "";
lessonState.branchStepIndex = 0;
delete selectedAnswers.value[stepIndex];
confirmSelectedAnswer.value = null;
stopAllAudio();
if (focusMode.value) nextTick(() => startStepAudio());
}
function randomCorrectVariant(): number {
return Math.floor(Math.random() * 4) + 1;
}
function selectOption(opt: string) {
if (hasAnswered.value) return;
if (lessonState.mode === "main") {
selectedAnswers.value[lessonState.stepIndex] = opt;
const mainStep = steps.value[lessonState.stepIndex];
if (focusMode.value) {
stopAllAudio();
if (opt === mainStep?.answer) {
// correct in focus mode — affirmation → explanation → advance
const v = randomCorrectVariant();
const clips: string[] = [`/audio/correct_${v}.mp3`];
const explanationPath = (mainStep as any).explanationAudioPath as string | undefined;
if (explanationPath) clips.push(explanationPath);
playSequence(clips).then(() => {
advance();
}).catch(() => {});
} else if (mainStep?.branches?.[opt]) {
// wrong with branch — transition fires automatically (same as non-focus)
setTimeout(() => enterBranchMode(lessonState.stepIndex, opt), 700);
}
// wrong with no branch — nothing plays, student sees explanation, can tap continue
} else {
if (mainStep && opt !== mainStep.answer && mainStep.branches?.[opt]) {
setTimeout(() => enterBranchMode(lessonState.stepIndex, opt), 700);
}
}
} else if (lessonState.mode === "confirm") {
selectConfirmOption(opt);
}
}
// ── navigation ─────────────────────────────────────────────────────────────
function advance() {
if (lessonState.mode === "branch") {
advanceBranchStep();
return;
}
if (lessonState.mode !== "main") return;
if (!canContinue.value) return;
if (isLastMainStep.value) { completeLesson(); return; }
lessonState.stepIndex++;
}
const lessonScore = ref(0);
watch(() => ({ ...selectedAnswers.value }), (opts, prev) => {
const newKeys = Object.keys(opts).map(Number).filter(k => !(k in prev));
for (const k of newKeys) {
const s = steps.value[k];
if (s?.type === "question" && opts[k] === s.answer) lessonScore.value++;
}
});
watch(() => [lessonState.mode, lessonState.stepIndex], ([mode, idx]) => {
if (mode === "main" && steps.value[idx as number]?.type === "summary") fireCelebration();
});
const celebrationFired = ref(false);
function fireCelebration() {
if (celebrationFired.value) return;
celebrationFired.value = true;
const count = 160;
const defaults = { startVelocity: 35, spread: 80, ticks: 70, zIndex: 200 };
const interval = setInterval(() => {
confetti({ ...defaults, particleCount: count * 0.3, origin: { x: 0.4, y: -0.1 }, colors: ["#6366F1", "#10B981", "#F59E0B", "#EC4899", "#ffffff"] });
confetti({ ...defaults, particleCount: count * 0.3, origin: { x: 0.6, y: -0.1 }, colors: ["#6366F1", "#10B981", "#F59E0B", "#EC4899", "#ffffff"] });
}, 180);
setTimeout(() => clearInterval(interval), 900);
}
const lessonDone = ref(false);
const completing = ref(false);
const completeError = ref(false);
async function completeLesson() {
if (completing.value || lessonDone.value) return;
completing.value = true;
completeError.value = false;
try {
const existing = localStorage.getItem(`progress:${topicId}`);
const prev = existing ? JSON.parse(existing) : {};
localStorage.setItem(`progress:${topicId}`, JSON.stringify({
...prev,
lessonComplete: true,
tookBranches: branchesEntered.value > 0,
branchCount: branchesEntered.value,
}));
lessonDone.value = true;
} catch {
completing.value = false;
completeError.value = true;
return;
} finally {
completing.value = false;
}
}
const router = useRouter();
function goBack() {
if (history.length <= 1) { router.push("/"); return; }
history.back();
}
function stepBack() {
stopAllAudio();
if (lessonState.mode === "branch" || lessonState.mode === "confirm" || lessonState.mode === "hardStop") {
// exit branch back to main question
lessonState.mode = "main";
lessonState.branchOption = "";
lessonState.branchStepIndex = 0;
confirmSelectedAnswer.value = null;
return;
}
if (lessonState.stepIndex > 0) {
lessonState.stepIndex--;
// clear the answer for this step so they can re-answer
delete selectedAnswers.value[lessonState.stepIndex];
}
}
// ── FOCUS MODE ────────────────────────────────────────────────────────────
const focusMode = ref(false);
const currentChunk = ref<{ text: string; start: number; end: number } | null>(null);
const karaokeAudioEl = ref<HTMLAudioElement | null>(null);
const questionPlaying = ref(false);
const narratingOptionIndex = ref<number | null>(null);;
const focusEverActivated = ref(false);
if (import.meta.client) {
const stored = localStorage.getItem("revisione-focus-mode");
if (stored === "true") focusMode.value = true;
}
const hasAudio = computed(() => {
const s = steps.value[0];
return !!(s?.audioPath || s?.questionAudioPath);
});
const showKaraoke = computed(() => {
if (!focusMode.value) return false;
const ds = displayStep.value;
if (!((lessonState.mode === "main" || lessonState.mode === "branch") &&
(ds?.type === "concept" || ds?.type === "example"))) return false;
// dont show overlay if this step has no audio — avoids perma-"..." with missing TTS
return !!(ds?.audioPath && ds?.audioChunks && (ds.audioChunks as any[]).length > 0);
});
// kept for compat — no longer drives a full overlay, just used to gate the continue button
const showQuestionOverlay = computed(() =>
focusMode.value &&
(lessonState.mode === "main" || lessonState.mode === "confirm") &&
displayStep.value?.type === "question" &&
questionPlaying.value
);
async function toggleFocusMode() {
focusMode.value = !focusMode.value;
if (import.meta.client) localStorage.setItem("revisione-focus-mode", String(focusMode.value));
if (focusMode.value) {
// on first activation, re-fetch lesson so we have the latest audio paths from the DB
if (!focusEverActivated.value) {
focusEverActivated.value = true;
try {
const fresh = await $fetch<any>(`/api/topics/${topicId}/lesson`);
lesson.value = fresh;
const s0 = fresh?.content?.steps?.[0];
console.log("[focus] step 0 audioPath:", s0?.audioPath, "| chunks:", s0?.audioChunks?.length ?? "none");
} catch (e) {
console.warn("[focus] re-fetch failed, using cached lesson data", e);
}
}
nextTick(() => startStepAudio());
} else {
stopAllAudio();
}
}
// ── karaoke ────────────────────────────────────────────────────────────────
let timeupdateHandler: (() => void) | null = null;
let endedHandler: (() => void) | null = null;
function startKaraoke(
audioEl: HTMLAudioElement,
chunks: { text: string; start: number; end: number }[],
onEnd: () => void
) {
currentChunk.value = chunks[0] ?? null;
timeupdateHandler = () => {
const t = audioEl.currentTime;
const found = chunks.find(c => t >= c.start && t < c.end);
if (found) currentChunk.value = found;
};
endedHandler = async () => {
currentChunk.value = null;
await new Promise(r => setTimeout(r, 600));
onEnd();
};
audioEl.addEventListener("timeupdate", timeupdateHandler);
audioEl.addEventListener("ended", endedHandler);
audioEl.play().catch(() => {});
}
function stopAllAudio() {
if (karaokeAudioEl.value) {
const a = karaokeAudioEl.value;
a.pause();
if (timeupdateHandler) { a.removeEventListener("timeupdate", timeupdateHandler); timeupdateHandler = null; }
if (endedHandler) { a.removeEventListener("ended", endedHandler); endedHandler = null; }
}
stopQueue();
}
// ── question audio queue ───────────────────────────────────────────────────
let queueAbort: (() => void) | null = null;
function stopQueue() {
if (queueAbort) { queueAbort(); queueAbort = null; }
questionPlaying.value = false;
}
async function playSequence(urls: string[]): Promise<void> {
return new Promise((resolve, reject) => {
let cancelled = false;
let current: HTMLAudioElement | null = null;
queueAbort = () => {
cancelled = true;
current?.pause();
reject(new Error("aborted"));
};
let i = 0;
function playNext() {
if (cancelled || i >= urls.length) { resolve(); return; }
const url = urls[i++];
if (!url) { playNext(); return; }
const audio = new Audio(url);
current = audio;
audio.onended = playNext;
audio.onerror = playNext;
audio.play().catch(playNext);
}
playNext();
});
}
function getVariant(stepIndex: number): number {
return (Math.abs(stepIndex) % 4) + 1;
}
async function playQuestionLike(
questionStep: Step,
stepIndex: number,
variantKey: number
) {
stopQueue();
questionPlaying.value = true;
narratingOptionIndex.value = null;
const variant = getVariant(variantKey);
try {
// play question stem first
if (questionStep.questionAudioPath) {
await playSequence([questionStep.questionAudioPath]);
}
// play each option in order, highlighting it while it plays
for (let oi = 0; oi < shuffledOptions.value.length; oi++) {
narratingOptionIndex.value = oi;
const label = LABELS[oi];
const origIdx = (questionStep.options ?? []).indexOf(shuffledOptions.value[oi]);
const optAudio = questionStep.optionAudioPaths?.[origIdx];
const clips: string[] = [`/audio/labels/${label}_${variant}.mp3`];
if (optAudio) clips.push(optAudio);
await playSequence(clips);
}
} catch {
// aborted
}
narratingOptionIndex.value = null;
questionPlaying.value = false;
}
function startStepAudio() {
if (!focusMode.value) return;
if (lessonState.mode === "main") {
const s = steps.value[lessonState.stepIndex];
if (!s) return;
if (s.type === "concept" || s.type === "example" || s.type === "summary") {
if (!s.audioPath || !s.audioChunks || (s.audioChunks as any[]).length === 0) {
// no usable audio — advance state only, the watcher fires startStepAudio for the new step
if (!isLastMainStep.value) {
lessonState.stepIndex++;
} else {
completeLesson();
}
return;
}
const audio = karaokeAudioEl.value;
if (!audio) return;
audio.src = s.audioPath;
audio.load();
startKaraoke(audio, s.audioChunks, () => {
if (!isLastMainStep.value) {
lessonState.stepIndex++;
// watcher handles startStepAudio for the new step
} else {
completeLesson();
}
});
} else if (s.type === "question") {
stopAllAudio();
playQuestionLike(s, lessonState.stepIndex, lessonState.stepIndex);
}
} else if (lessonState.mode === "branch") {
const bs = currentBranchStep.value;
if (!bs?.audioPath || !bs.audioChunks || (bs.audioChunks as any[]).length === 0) {
advanceBranchStep();
return;
}
const audio = karaokeAudioEl.value;
if (!audio) return;
audio.src = bs.audioPath;
audio.load();
startKaraoke(audio, bs.audioChunks, () => {
advanceBranchStep();
});
} else if (lessonState.mode === "confirm") {
const cq = currentBranch.value?.confirmQuestion;
if (!cq) return;
const questionStep: Step = {
type: "question",
body: cq.body,
options: cq.options,
answer: cq.answer,
explanation: cq.explanation,
questionAudioPath: cq.questionAudioPath,
questionAudioChunks: cq.questionAudioChunks,
optionAudioPaths: cq.optionAudioPaths,
};
stopAllAudio();
playQuestionLike(questionStep, lessonState.stepIndex, -1);
}
}
watch(() => [lessonState.mode, lessonState.stepIndex, lessonState.branchStepIndex], () => {
if (focusMode.value && lessonState.mode !== "transition" && lessonState.mode !== "hardStop") {
stopAllAudio();
nextTick(() => startStepAudio());
}
});
watch(focusMode, (on) => { if (!on) stopAllAudio(); });
function skipStep() {
stopAllAudio();
if (lessonState.mode === "branch") {
advanceBranchStep();
} else if (!isLastMainStep.value) {
lessonState.stepIndex++;
} else {
completeLesson();
}
}
function continueAfterQuestion() {
stopQueue();
advance();
}
onBeforeUnmount(() => {
stopAllAudio();
stopBranchPoll();
if (transitionTimeout) clearTimeout(transitionTimeout);
if (confirmAdvanceTimeout) clearTimeout(confirmAdvanceTimeout);
});
</script>
<template>
<!-- JIT GENERATION LOADING SCREEN -->
<Transition name="fullscreen-fade">
<div v-if="genState === 'loading'" class="gen-loading-screen">
<div class="gen-inner">
<div class="gen-logo">Revisi<span>.one</span></div>
<div class="gen-pulse-ring">
<div class="gen-pulse-circle" />
<div class="gen-pulse-dot">
<svg width="28" height="28" fill="none" viewBox="0 0 24 24" stroke="oklch(54% 0.140 44)">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
</div>
</div>
<Transition name="tip">
<p v-if="genTipVisible" :key="genTipIndex" class="gen-tip">{{ GEN_TIPS[genTipIndex] }}</p>
</Transition>
<div class="gen-stepper">
<div
v-for="(stage, i) in GEN_STAGES"
:key="i"
class="gen-step"
:class="i === genStageIndex ? 'gen-step--active' : i < genStageIndex ? 'gen-step--done' : 'gen-step--pending'"
>
<div class="gen-step-node">
<div v-if="i < genStageIndex" class="gen-node-done">
<svg width="10" height="10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/>
</svg>
</div>
<div v-else-if="i === genStageIndex" class="gen-node-active">
<div class="gen-node-ring" />
<div class="gen-node-dot" />
</div>
<div v-else class="gen-node-pending" />
</div>
<span class="gen-step-label">{{ stage }}</span>
</div>
</div>
</div>
</div>
</Transition>
<!-- JIT GENERATION ERROR -->
<Transition name="fullscreen-fade">
<div v-if="genState === 'error'" class="gen-loading-screen">
<div class="gen-inner">
<div class="gen-logo">Revisi<span>.one</span></div>
<p class="gen-error-msg">We couldn't generate this lesson. Please try again.</p>
<button class="gen-retry-btn" @click="triggerGeneration">Try again</button>
</div>
</div>
</Transition>
<div
v-if="genState === 'ready' || genState === null"
class="lesson-shell"
:style="{ '--step-bg': stepBg, '--accent': accentColor }"
>
<div class="accent-bar" />
<audio ref="karaokeAudioEl" preload="auto" style="display:none" />
<!-- ── TOP BAR ─────────────────────────────────────────────────────────── -->
<header class="topbar">
<button class="topbar-back" @click="stepBack" aria-label="Previous step" :disabled="lessonState.stepIndex === 0 && lessonState.mode === 'main'">
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</button>
<div class="progress-segments" role="progressbar" :aria-valuenow="lessonState.stepIndex + 1" :aria-valuemax="totalSteps">
<div
v-for="i in totalSteps"
:key="i"
class="seg"
:class="{ 'seg--filled': i - 1 <= lessonState.stepIndex, 'seg--empty': i - 1 > lessonState.stepIndex }"
/>
</div>
<span class="topbar-count">{{ lessonPending ? '' : `${lessonState.stepIndex + 1} / ${totalSteps}` }}</span>
<button
v-if="hasAudio && !lessonPending"
class="focus-toggle"
:class="{ 'focus-toggle--on': focusMode }"
@click="toggleFocusMode"
:title="focusMode ? 'Exit Focus Mode' : 'Focus Mode'"
aria-label="Toggle Focus Mode"
>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
</svg>
<span class="focus-label">Focus Mode</span>
</button>
<button class="topbar-exit" @click="goBack" aria-label="Exit lesson" title="Back to course">
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</header>
<!-- ── KARAOKE OVERLAY ────────────────────────────────────────────────── -->
<Transition name="karaoke-fade">
<div v-if="showKaraoke" class="karaoke-overlay" :style="{ backgroundColor: stepBg }">
<template v-if="currentChunk">
<p class="karaoke-text">{{ currentChunk.text }}</p>
<button class="karaoke-skip" @click="skipStep" title="Skip">
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
</template>
<template v-else>
<button class="karaoke-start" @click="startStepAudio">
<svg width="22" height="22" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
Start
</button>
</template>
</div>
</Transition>
<!-- ── BRANCH TRANSITION SCREEN ───────────────────────────────────────── -->
<Transition name="fullscreen-fade">
<div v-if="lessonState.mode === 'transition'" class="branch-transition-screen">
<div class="transition-inner">
<div class="transition-icon">
<svg width="40" height="40" fill="none" viewBox="0 0 24 24" stroke="#F59E0B">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
</div>
<p class="transition-text">Let's look at this a different way</p>
</div>
</div>
</Transition>
<!-- HARD STOP SCREEN -->
<Transition name="fullscreen-fade">
<div v-if="lessonState.mode === 'hardStop'" class="hard-stop-screen">
<div class="hard-stop-inner">
<div class="hard-stop-icon">
<svg width="48" height="48" fill="none" viewBox="0 0 24 24" stroke="#F59E0B">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.25"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
</div>
<p class="hard-stop-text">
{{ currentBranch?.hardStop ?? "This concept needs a bit more time. Take a break, do some reading, then come back." }}
</p>
<button class="hard-stop-btn" @click="retryFromHardStop">
I've done some reading let me try again
</button>
</div>
</div>
</Transition>
<!-- MAIN -->
<main
class="lesson-main"
v-show="!showKaraoke && lessonState.mode !== 'transition' && lessonState.mode !== 'hardStop'"
>
<div v-if="lessonPending" class="state-center">
<div class="indigo-dots"><span /><span /><span /></div>
<p class="state-label">Loading lesson</p>
</div>
<div v-else-if="lessonError" class="err-state">
<p>Couldn't load this lesson. Please go back and try again.</p>
</div>
<div v-else-if="lesson" class="step-container">
<Transition name="step" mode="out-in">
<div :key="stepKey" class="step-inner">
<!-- confirm accent bar -->
<div v-if="lessonState.mode === 'confirm'" class="confirm-accent-bar" />
<p class="type-label" :style="{ color: accentColor }">{{ typeLabel }}</p>
<!-- ── CONCEPT ──────────────────────────────────────── -->
<template v-if="displayStep?.type === 'concept'">
<h1 class="step-title">{{ displayStep.title }}</h1>
<p class="step-body">{{ displayStep.body }}</p>
</template>
<!-- ── EXAMPLE ──────────────────────────────────────── -->
<template v-else-if="displayStep?.type === 'example'">
<h1 class="step-title">{{ displayStep.title }}</h1>
<p class="step-body">{{ displayStep.body }}</p>
<div v-if="displayStep.callout" class="callout">
<p class="callout-text">{{ displayStep.callout }}</p>
</div>
</template>
<!-- ── QUESTION (main + confirm) ─────────────────────── -->
<template v-else-if="displayStep?.type === 'question'">
<p class="question-text">{{ displayStep.body }}</p>
<div class="options-list">
<button
v-for="(opt, i) in shuffledOptions"
:key="opt"
class="option"
:class="{
'option--hover-ready': !hasAnswered && narratingOptionIndex === null,
'option--narrating': !hasAnswered && narratingOptionIndex === i,
'option--correct': hasAnswered && opt === displayStep.answer,
'option--wrong': hasAnswered && selectedAnswer === opt && opt !== displayStep.answer,
'option--dim': hasAnswered && selectedAnswer !== opt && opt !== displayStep.answer,
}"
:disabled="hasAnswered"
@click="selectOption(opt)"
>
<span class="option-badge">{{ LABELS[i] }}</span>
<span class="option-text">{{ opt }}</span>
<span v-if="hasAnswered && opt === displayStep.answer" class="option-mark option-mark--check">
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/>
</svg>
</span>
<span v-else-if="hasAnswered && selectedAnswer === opt && opt !== displayStep.answer" class="option-mark option-mark--x">
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12"/>
</svg>
</span>
</button>
</div>
<!-- main question feedback -->
<Transition name="feedback">
<div
v-if="lessonState.mode === 'main' && showMainExplanation"
class="explanation"
:class="isCorrect ? 'explanation--correct' : 'explanation--wrong'"
>
<span class="explanation-verdict">{{ isCorrect ? 'Correct!' : 'Not quite.' }}</span>
{{ displayStep.explanation }}
</div>
<div
v-else-if="lessonState.mode === 'confirm' && hasAnswered"
class="explanation"
:class="isCorrect ? 'explanation--correct' : 'explanation--wrong'"
>
<span class="explanation-verdict">{{ isCorrect ? "You've got it." : "Not quite." }}</span>
{{ displayStep.explanation }}
</div>
<p v-else-if="!hasAnswered" class="answer-hint">Select an answer to continue</p>
</Transition>
</template>
<!-- ── SUMMARY ──────────────────────────────────────── -->
<template v-else-if="displayStep?.type === 'summary'">
<div class="summary-icon">✦</div>
<h1 class="step-title summary-title">{{ displayStep.title }}</h1>
<ul class="bullet-list">
<li
v-for="(b, i) in displayStep.bullets"
:key="i"
class="bullet-item"
:style="{ animationDelay: `${i * 150}ms` }"
>
<span class="bullet-check">
<svg width="10" height="10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5" d="M5 13l4 4L19 7"/>
</svg>
</span>
<span>{{ b }}</span>
</li>
</ul>
<div v-if="lessonDone" class="done-msg">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" style="color:#10B981">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/>
</svg>
Lesson complete — great work.
<button class="back-btn" @click="goBack">← Back to course</button>
</div>
<button
v-if="!lessonDone && !completeError"
class="summary-cta"
:disabled="completing"
@click="completeLesson"
>
{{ completing ? 'Saving…' : 'Complete Lesson ✓' }}
</button>
<button v-if="completeError" class="summary-cta" @click="completeLesson">Retry ✓</button>
</template>
</div>
</Transition>
</div>
</main>
<!-- ── CONTINUE FOOTER ───────────────────────────────────────────────── -->
<template v-if="!showKaraoke && lessonState.mode !== 'transition' && lessonState.mode !== 'hardStop'">
<!-- non-question, non-summary steps -->
<div
v-if="!lessonPending && !lessonError && lesson && displayStep?.type !== 'question' && displayStep?.type !== 'summary'"
class="continue-zone"
>
<Transition name="continue-btn">
<button v-if="canContinue" class="continue-pill" @click="advance">
Continue
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7"/>
</svg>
</button>
</Transition>
</div>
<!-- question step — show continue once explanation visible; in focus mode only show for wrong+no-branch (correct and wrong+branch auto-advance) -->
<div
v-if="!lessonPending && !lessonError && lesson && displayStep?.type === 'question' && showMainExplanation && (!focusMode || !isCorrect)"
class="continue-zone"
>
<Transition name="continue-btn">
<button class="continue-pill" @click="focusMode ? continueAfterQuestion() : advance()">
{{ isLastMainStep && lessonState.mode === 'main' ? 'Finish Lesson' : 'Continue' }}
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7"/>
</svg>
</button>
</Transition>
</div>
</template>
<!-- ── BRANCH LOADING OVERLAY ────────────────────────────────────────── -->
<Transition name="fullscreen-fade">
<div v-if="branchOverlayVisible" class="branch-loading-overlay">
<div class="branch-loading-inner">
<template v-if="!branchOverlayError">
<div class="branch-loading-pulse">
<div class="branch-loading-ring" />
<div class="branch-loading-icon">
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="#92400E">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
</div>
</div>
<p class="branch-loading-text">Preparing personalised feedback…</p>
</template>
<template v-else>
<p class="branch-loading-error">We couldn't prepare feedback for this answer.</p>
<button class="branch-loading-skip" @click="() => { branchOverlayVisible = false; pendingBranchStep = null; }">
Continue
</button>
</template>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.lesson-shell {
min-height: 100dvh;
display: flex;
flex-direction: column;
background-color: var(--step-bg, #FAFAF8);
transition: background-color 300ms ease;
position: relative;
}
.accent-bar {
position: fixed;
top: 0; left: 0; right: 0;
height: 4px;
background: var(--accent, #6366F1);
transition: background 300ms ease;
z-index: 50;
}
/* ── top bar ─────────────────────────────────────────────────────────────── */
.topbar {
position: sticky;
top: 4px;
z-index: 40;
background: oklch(97.5% 0.008 78 / 0.92);
backdrop-filter: blur(8px);
border-bottom: 1px solid oklch(88% 0.022 73);
height: 56px;
display: flex;
align-items: center;
gap: 1rem;
padding: 0 1.5rem;
}
.topbar-back {
color: oklch(20% 0.020 55);
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
flex-shrink: 0;
opacity: 0.5;
transition: opacity 0.15s;
}
.topbar-back:hover:not(:disabled) { opacity: 1; }
.topbar-back:disabled { opacity: 0.2; cursor: default; }
.topbar-exit {
color: oklch(20% 0.020 55);
background: none;
border: none;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
flex-shrink: 0;
opacity: 0.4;
transition: opacity 0.15s;
margin-left: 4px;
}
.topbar-exit:hover { opacity: 0.85; }
.progress-segments {
flex: 1;
display: flex;
gap: 4px;
align-items: center;
}
.seg {
flex: 1;
height: 3px;
border-radius: 4px;
transition: background 0.35s ease;
}
.seg--filled { background: oklch(54% 0.140 44); }
.seg--empty { background: oklch(88% 0.022 73); }
.topbar-count {
font-size: 12px;
color: oklch(60% 0.016 68);
flex-shrink: 0;
min-width: 3rem;
text-align: right;
letter-spacing: 0.04em;
}
.focus-toggle {
display: flex;
align-items: center;
gap: 6px;
background: none;
border: 1px solid oklch(82% 0.026 70);
border-radius: 20px;
padding: 5px 12px;
cursor: pointer;
color: oklch(60% 0.016 68);
font-size: 12px;
font-weight: 500;
flex-shrink: 0;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.focus-toggle:hover { border-color: oklch(54% 0.140 44); color: oklch(54% 0.140 44); }
.focus-toggle--on { border-color: oklch(54% 0.140 44); background: oklch(93% 0.040 76); color: oklch(54% 0.140 44); }
.focus-label { white-space: nowrap; }
/* ── karaoke overlay ─────────────────────────────────────────────────────── */
.karaoke-overlay {
position: fixed;
inset: 0;
z-index: 35;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.karaoke-text {
font-family: "Lora", Georgia, serif;
font-size: 2.5rem;
font-weight: 600;
color: oklch(20% 0.020 55);
max-width: 500px;
text-align: center;
line-height: 1.3;
}
.karaoke-text--question {
font-size: 1.25rem;
font-weight: 400;
color: #6B7280;
font-style: italic;
}
.karaoke-skip {
position: fixed;
bottom: max(2rem, env(safe-area-inset-bottom));
right: 2rem;
background: none;
border: none;
cursor: pointer;
color: #9CA3AF;
opacity: 0.45;
transition: opacity 0.15s;
padding: 8px;
}
.karaoke-skip:hover { opacity: 0.85; }
.karaoke-start {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: oklch(54% 0.140 44);
color: #fff;
border: none;
border-radius: 9999px;
padding: 0.85rem 2rem;
font-family: "IBM Plex Mono", monospace;
font-size: 0.875rem;
font-weight: 500;
letter-spacing: 0.04em;
cursor: pointer;
box-shadow: 0 4px 20px rgba(0,0,0,0.12);
transition: opacity 0.15s, transform 0.1s;
}
.karaoke-start:hover { opacity: 0.88; }
.karaoke-start:active { transform: scale(0.97); }
.karaoke-fade-enter-active { transition: opacity 0.2s ease; }
.karaoke-fade-leave-active { transition: opacity 0.15s ease; }
.karaoke-fade-enter-from,
.karaoke-fade-leave-to { opacity: 0; }
/* ── branch transition screen ────────────────────────────────────────────── */
.branch-transition-screen {
position: fixed;
inset: 0;
z-index: 45;
background: #FFFBEB;
display: flex;
align-items: center;
justify-content: center;
}
.transition-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
padding: 2rem;
text-align: center;
}
.transition-icon {
opacity: 0.8;
animation: float 2s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.transition-text {
font-family: "DM Serif Display", Georgia, serif;
font-size: 1.5rem;
color: #92400E;
line-height: 1.4;
}
/* ── hard stop screen ────────────────────────────────────────────────────── */
.hard-stop-screen {
position: fixed;
inset: 0;
z-index: 45;
background: #FFFBEB;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.hard-stop-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
max-width: 480px;
text-align: center;
}
.hard-stop-icon {
opacity: 0.75;
}
.hard-stop-text {
font-family: "DM Serif Display", Georgia, serif;
font-size: 1.1875rem;
line-height: 1.7;
color: #92400E;
}
.hard-stop-btn {
background: #F59E0B;
color: #ffffff;
border: none;
border-radius: 9999px;
padding: 0.85rem 1.75rem;
font-family: "DM Sans", system-ui, sans-serif;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
max-width: 360px;
line-height: 1.4;
}
.hard-stop-btn:hover { background: #D97706; }
.hard-stop-btn:active { transform: scale(0.98); }
.fullscreen-fade-enter-active { transition: opacity 0.3s ease; }
.fullscreen-fade-leave-active { transition: opacity 0.2s ease; }
.fullscreen-fade-enter-from,
.fullscreen-fade-leave-to { opacity: 0; }
/* ── confirm accent bar ──────────────────────────────────────────────────── */
.confirm-accent-bar {
height: 3px;
background: #F59E0B;
border-radius: 9999px;
margin-bottom: 1.5rem;
width: 48px;
}
/* ── main ────────────────────────────────────────────────────────────────── */
.lesson-main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 3rem 0 8rem;
}
.step-container {
width: 100%;
max-width: 640px;
margin: 0 auto;
padding: 0 24px;
}
.step-inner { width: 100%; }
.type-label {
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
margin-bottom: 1rem;
transition: color 300ms ease;
font-weight: 600;
}
.step-title {
font-family: "Lora", Georgia, serif;
font-size: 2rem;
line-height: 1.2;
color: oklch(20% 0.020 55);
margin-bottom: 1.5rem;
font-weight: 600;
}
.step-body {
font-family: "DM Sans", system-ui, sans-serif;
font-size: 1.0625rem;
line-height: 1.8;
color: oklch(35% 0.018 58);
font-weight: 400;
}
.callout {
margin-top: 1.75rem;
padding: 1rem 1.25rem;
background: #FEF3C7;
border-left: 3px solid #F59E0B;
border-radius: 8px;
}
.callout-text {
font-family: Georgia, serif;
font-size: 0.95rem;
line-height: 1.65;
color: #92400E;
font-style: italic;
margin: 0;
}
.question-text {
font-family: "Lora", Georgia, serif;
font-size: 1.25rem;
line-height: 1.55;
color: oklch(20% 0.020 55);
font-weight: 500;
font-style: italic;
margin-bottom: 2rem;
}
.options-list {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 1.25rem;
}
.option {
display: flex;
align-items: center;
gap: 0.875rem;
width: 100%;
text-align: left;
padding: 18px 20px;
border: 1.5px solid #C7D2FE;
border-radius: 12px;
background: #ffffff;
cursor: pointer;
transition: border-color 0.15s, background 0.15s, transform 0.2s, opacity 0.2s;
font-size: 1rem;
}
.option--hover-ready:hover { border-color: #6366F1; background: #F5F3FF; }
.option--narrating { border-color: #6366F1; background: #EEF2FF; box-shadow: 0 0 0 3px rgba(99,102,241,0.15); }
.option--correct { border-color: #10B981; background: #D1FAE5; animation: spring-correct 0.3s ease-out; }
.option--wrong { border-color: #EF4444; background: #FEE2E2; }
.option--dim { opacity: 0.38; }
@keyframes spring-correct {
0% { transform: scale(1); }
40% { transform: scale(1.025); }
70% { transform: scale(0.995); }
100% { transform: scale(1); }
}
.option-badge {
font-family: "IBM Plex Mono", monospace;
font-size: 0.68rem;
font-weight: 500;
color: #6366F1;
background: #EEF2FF;
width: 24px;
height: 24px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.15s, color 0.15s;
}
.option--correct .option-badge { background: #A7F3D0; color: #065F46; }
.option--wrong .option-badge { background: #FECACA; color: #991B1B; }
.option--dim .option-badge { background: #F3F4F6; color: #9CA3AF; }
.option-text {
flex: 1;
font-family: Georgia, serif;
font-size: 1rem;
color: #374151;
line-height: 1.5;
}
.option-mark {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.option-mark--check { background: #059669; color: #ffffff; }
.option-mark--x { background: #DC2626; color: #ffffff; }
.answer-hint {
font-family: Georgia, serif;
font-size: 0.8125rem;
color: #9CA3AF;
font-style: italic;
text-align: center;
margin-top: 0.5rem;
}
.explanation {
margin-top: 1.25rem;
padding: 1rem 1.25rem;
border-radius: 8px;
font-family: Georgia, serif;
font-size: 0.9375rem;
line-height: 1.65;
}
.explanation--correct { background: #ECFDF5; border-left: 3px solid #10B981; color: #065F46; }
.explanation--wrong { background: #FFF1F2; border-left: 3px solid #EF4444; color: #9F1239; }
.explanation-verdict {
font-family: "IBM Plex Mono", monospace;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
display: block;
margin-bottom: 0.4rem;
}
.summary-icon {
font-size: 2.5rem;
color: #10B981;
display: block;
margin-bottom: 1.25rem;
line-height: 1;
}
.summary-title { font-size: 1.75rem; }
.bullet-list {
display: flex;
flex-direction: column;
gap: 0.875rem;
margin-top: 1.5rem;
margin-bottom: 2rem;
padding: 0;
list-style: none;
}
.bullet-item {
display: flex;
align-items: flex-start;
gap: 0.875rem;
font-family: Georgia, serif;
font-size: 1.0625rem;
line-height: 1.6;
color: #374151;
opacity: 0;
transform: translateY(8px);
animation: bullet-in 0.35s ease-out forwards;
}
@keyframes bullet-in {
to { opacity: 1; transform: translateY(0); }
}
.bullet-check {
width: 22px;
height: 22px;
border-radius: 50%;
background: #10B981;
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2px;
}
.summary-cta {
width: 100%;
height: 52px;
background: #10B981;
color: #ffffff;
border: none;
border-radius: 12px;
font-family: "IBM Plex Mono", monospace;
font-size: 0.875rem;
font-weight: 500;
letter-spacing: 0.05em;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
margin-top: 0.5rem;
}
.summary-cta:hover:not(:disabled) { opacity: 0.88; }
.summary-cta:active:not(:disabled) { transform: scale(0.99); }
.summary-cta:disabled { opacity: 0.5; cursor: not-allowed; }
.done-msg {
display: flex;
align-items: center;
gap: 0.5rem;
font-family: "IBM Plex Mono", monospace;
font-size: 0.78rem;
color: #6B7280;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.back-btn {
background: none;
border: none;
cursor: pointer;
font-family: "IBM Plex Mono", monospace;
font-size: 0.78rem;
color: #6366F1;
padding: 0;
text-decoration: underline;
transition: opacity 0.15s;
}
.back-btn:hover { opacity: 0.7; }
/* ── loading / error ─────────────────────────────────────────────────────── */
.state-center {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 5rem 0;
width: 100%;
}
.indigo-dots { display: flex; gap: 5px; }
.indigo-dots span {
width: 8px;
height: 8px;
border-radius: 50%;
background: #6366F1;
animation: dot-bounce 1.2s ease-in-out infinite;
}
.indigo-dots span:nth-child(2) { animation-delay: 0.15s; }
.indigo-dots span:nth-child(3) { animation-delay: 0.3s; }
@keyframes dot-bounce {
0%, 80%, 100% { opacity: 0.25; transform: scale(0.75); }
40% { opacity: 1; transform: scale(1); }
}
.state-label {
font-family: "IBM Plex Mono", monospace;
font-size: 0.78rem;
letter-spacing: 0.1em;
color: #6B7280;
text-transform: uppercase;
}
.err-state {
max-width: 480px;
font-family: Georgia, serif;
font-size: 0.9375rem;
color: #B91C1C;
background: #FFF1F2;
border: 1px solid #FECACA;
border-radius: 8px;
padding: 1.25rem 1.5rem;
}
/* ── continue zone ───────────────────────────────────────────────────────── */
.continue-zone {
position: fixed;
bottom: 0; left: 0; right: 0;
display: flex;
justify-content: center;
padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
padding-top: 2.5rem;
background: linear-gradient(to top, var(--step-bg, #FAFAF8) 55%, transparent);
pointer-events: none;
z-index: 30;
transition: background 300ms ease;
}
.continue-pill {
pointer-events: all;
display: inline-flex;
align-items: center;
gap: 0.5rem;
width: 280px;
height: 52px;
justify-content: center;
background: #6366F1;
color: #ffffff;
border: none;
border-radius: 9999px;
font-family: "IBM Plex Mono", monospace;
font-size: 0.875rem;
font-weight: 500;
letter-spacing: 0.04em;
cursor: pointer;
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.3);
transition: opacity 0.15s, transform 0.15s, box-shadow 0.15s;
}
.continue-pill:hover { opacity: 0.92; transform: scale(0.98); box-shadow: 0 2px 12px rgba(99, 102, 241, 0.25); }
.continue-pill:active { transform: scale(0.96); }
/* ── transitions ─────────────────────────────────────────────────────────── */
.step-enter-active { transition: opacity 0.2s ease; }
.step-leave-active { transition: opacity 0.15s ease; }
.step-enter-from, .step-leave-to { opacity: 0; }
.feedback-enter-active { transition: opacity 0.25s ease, transform 0.25s ease; }
.feedback-leave-active { transition: opacity 0.15s ease; }
.feedback-enter-from { opacity: 0; transform: translateY(8px); }
.feedback-leave-to { opacity: 0; }
.continue-btn-enter-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.continue-btn-leave-active { transition: opacity 0.15s ease; }
.continue-btn-enter-from { opacity: 0; transform: translateY(8px); }
.continue-btn-leave-to { opacity: 0; }
@media (max-width: 500px) {
.lesson-main { padding: 2rem 0 7rem; }
.step-title { font-size: 1.625rem; }
.step-body { font-size: 1rem; }
.focus-label { display: none; }
}
/* ── JIT generation loading screen ──────────────────────────────────────── */
.gen-loading-screen {
position: fixed;
inset: 0;
z-index: 100;
background: #FAFAF8;
display: flex;
align-items: center;
justify-content: center;
}
.gen-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
padding: 2rem;
max-width: 380px;
width: 100%;
}
.gen-logo {
font-family: "DM Serif Display", Georgia, serif;
font-size: 1.5rem;
color: oklch(35% 0.018 58);
letter-spacing: -0.02em;
}
.gen-logo span {
color: oklch(54% 0.140 44);
}
.gen-pulse-ring {
position: relative;
width: 72px;
height: 72px;
display: flex;
align-items: center;
justify-content: center;
}
.gen-pulse-circle {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid oklch(82% 0.055 76);
animation: gen-pulse 2s ease-in-out infinite;
}
@keyframes gen-pulse {
0%, 100% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.15); opacity: 0.3; }
}
.gen-pulse-dot {
width: 52px;
height: 52px;
border-radius: 50%;
background: oklch(95% 0.030 76);
display: flex;
align-items: center;
justify-content: center;
border: 1.5px solid oklch(85% 0.045 72);
}
.gen-tip {
font-family: Georgia, serif;
font-size: 0.9375rem;
color: oklch(50% 0.016 65);
font-style: italic;
text-align: center;
line-height: 1.6;
min-height: 2.5rem;
}
.gen-stepper {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
}
.gen-step {
display: flex;
align-items: center;
gap: 0.875rem;
}
.gen-step-node {
flex-shrink: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.gen-node-done {
width: 20px;
height: 20px;
border-radius: 50%;
background: oklch(54% 0.140 44);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.gen-node-active {
position: relative;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.gen-node-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid oklch(54% 0.140 44);
animation: gen-pulse 1.5s ease-in-out infinite;
}
.gen-node-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: oklch(54% 0.140 44);
}
.gen-node-pending {
width: 8px;
height: 8px;
border-radius: 50%;
background: oklch(82% 0.022 73);
}
.gen-step-label {
font-family: "IBM Plex Mono", monospace;
font-size: 0.78rem;
letter-spacing: 0.04em;
transition: color 0.3s;
}
.gen-step--done .gen-step-label { color: oklch(54% 0.140 44); }
.gen-step--active .gen-step-label { color: oklch(35% 0.018 58); font-weight: 600; }
.gen-step--pending .gen-step-label { color: oklch(72% 0.016 65); }
.gen-error-msg {
font-family: Georgia, serif;
font-size: 0.9375rem;
color: oklch(45% 0.18 25);
text-align: center;
line-height: 1.6;
}
.gen-retry-btn {
background: oklch(54% 0.140 44);
color: #fff;
border: none;
border-radius: 9999px;
padding: 0.75rem 2rem;
font-family: "IBM Plex Mono", monospace;
font-size: 0.875rem;
cursor: pointer;
transition: opacity 0.15s;
}
.gen-retry-btn:hover { opacity: 0.85; }
.tip-enter-active { transition: opacity 0.3s ease; }
.tip-leave-active { transition: opacity 0.2s ease; }
.tip-enter-from, .tip-leave-to { opacity: 0; }
/* ── branch loading overlay ──────────────────────────────────────────────── */
.branch-loading-overlay {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(255, 251, 235, 0.88);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
}
.branch-loading-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
padding: 2rem;
text-align: center;
}
.branch-loading-pulse {
position: relative;
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
}
.branch-loading-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid #F59E0B;
animation: gen-pulse 2s ease-in-out infinite;
}
.branch-loading-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: #FEF3C7;
display: flex;
align-items: center;
justify-content: center;
border: 1.5px solid #FCD34D;
}
.branch-loading-text {
font-family: "DM Serif Display", Georgia, serif;
font-size: 1.0625rem;
color: #92400E;
line-height: 1.5;
}
.branch-loading-error {
font-family: Georgia, serif;
font-size: 0.9375rem;
color: #92400E;
line-height: 1.6;
}
.branch-loading-skip {
background: #F59E0B;
color: #fff;
border: none;
border-radius: 9999px;
padding: 0.75rem 2rem;
font-family: "IBM Plex Mono", monospace;
font-size: 0.875rem;
cursor: pointer;
transition: opacity 0.15s;
}
.branch-loading-skip:hover { opacity: 0.85; }
</style>