2012 lines
63 KiB
Vue
2012 lines
63 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 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() {
|
|
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() {
|
|
lessonPending.value = true;
|
|
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 {
|
|
lessonPending.value = false;
|
|
}
|
|
}
|
|
|
|
async function triggerGeneration() {
|
|
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();
|
|
});
|
|
|
|
// ── branch loading overlay ─────────────────────────────────────────────────
|
|
|
|
const branchOverlayVisible = ref(false);
|
|
const branchOverlayError = ref(false);
|
|
|
|
let branchPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
function startBranchPoll() {
|
|
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 */ }
|
|
}, 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 fisherYates(arr: string[]): string[] {
|
|
const a = [...arr];
|
|
for (let i = a.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (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) {
|
|
shuffledOptions.value = fisherYates(s.options);
|
|
} 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, (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++;
|
|
}
|
|
}, { deep: true });
|
|
|
|
watch(() => [lessonState.mode, lessonState.stepIndex], ([mode, idx]) => {
|
|
if (mode === "main" && steps.value[idx as number]?.type === "summary") fireCelebration();
|
|
});
|
|
|
|
function fireCelebration() {
|
|
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);
|
|
|
|
async function completeLesson() {
|
|
if (completing.value || lessonDone.value) return;
|
|
completing.value = true;
|
|
try {
|
|
await $fetch(`/api/topics/${topicId}/progress`, {
|
|
method: "POST",
|
|
body: {
|
|
lessonComplete: true,
|
|
tookBranches: branchesEntered.value > 0,
|
|
branchCount: branchesEntered.value,
|
|
},
|
|
});
|
|
lessonDone.value = true;
|
|
} finally {
|
|
completing.value = false;
|
|
}
|
|
}
|
|
|
|
function goBack() { history.back(); }
|
|
|
|
// ── 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 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);
|
|
});
|
|
|
|
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();
|
|
});
|
|
}
|
|
|
|
const questionVariants = ref<Record<number, number>>({});
|
|
|
|
function getVariant(stepIndex: number): number {
|
|
if (!questionVariants.value[stepIndex]) {
|
|
questionVariants.value[stepIndex] = Math.floor(Math.random() * 4) + 1;
|
|
}
|
|
return questionVariants.value[stepIndex];
|
|
}
|
|
|
|
async function playQuestionLike(
|
|
questionStep: Step,
|
|
stepIndex: number,
|
|
variantKey: number
|
|
) {
|
|
stopQueue();
|
|
questionPlaying.value = true;
|
|
|
|
const variant = getVariant(variantKey);
|
|
const playlist: string[] = [];
|
|
|
|
if (questionStep.questionAudioPath) playlist.push(questionStep.questionAudioPath);
|
|
|
|
for (let oi = 0; oi < shuffledOptions.value.length; oi++) {
|
|
const label = LABELS[oi];
|
|
playlist.push(`/audio/labels/${label}_${variant}.mp3`);
|
|
const origIdx = (questionStep.options ?? []).indexOf(shuffledOptions.value[oi]);
|
|
const optAudio = questionStep.optionAudioPaths?.[origIdx];
|
|
if (optAudio) playlist.push(optAudio);
|
|
}
|
|
|
|
try {
|
|
await playSequence(playlist);
|
|
} catch {
|
|
// aborted
|
|
}
|
|
|
|
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 for this step — skip it automatically so focus mode stays alive
|
|
if (!isLastMainStep.value) {
|
|
lessonState.stepIndex++;
|
|
nextTick(() => startStepAudio());
|
|
} 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++;
|
|
nextTick(() => startStepAudio());
|
|
} 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="goBack" aria-label="Back">
|
|
<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>
|
|
</header>
|
|
|
|
<!-- ── KARAOKE OVERLAY ────────────────────────────────────────────────── -->
|
|
<Transition name="karaoke-fade">
|
|
<div v-if="showKaraoke" class="karaoke-overlay" :style="{ backgroundColor: stepBg }">
|
|
<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>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- ── QUESTION AUDIO OVERLAY ─────────────────────────────────────────── -->
|
|
<Transition name="karaoke-fade">
|
|
<div v-if="showQuestionOverlay" class="karaoke-overlay" :style="{ backgroundColor: stepBg }">
|
|
<p class="karaoke-text karaoke-text--question">Listen carefully…</p>
|
|
<button class="karaoke-skip" @click="stopQueue" title="Skip to question">
|
|
<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>
|
|
</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 && !showQuestionOverlay && 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,
|
|
'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"
|
|
class="summary-cta"
|
|
:disabled="completing"
|
|
@click="completeLesson"
|
|
>
|
|
{{ completing ? 'Saving…' : 'Complete Lesson ✓' }}
|
|
</button>
|
|
</template>
|
|
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- ── CONTINUE FOOTER ───────────────────────────────────────────────── -->
|
|
<template v-if="!showKaraoke && !showQuestionOverlay && 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 { opacity: 1; }
|
|
|
|
.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-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--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>
|